@jr2/orchestrator 0.2.1 → 0.4.1
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/bin/server.ts +7 -4
- package/console/canvas.ts +27 -14
- package/console/components/app.ts +10 -1
- package/console/components/fleet.ts +39 -6
- package/console/components/machine-pane.ts +89 -2
- package/console/components/nav.ts +1 -1
- package/console/components/schema-form.ts +12 -4
- package/console/json-field.ts +23 -0
- package/console/main.ts +110 -39
- package/console/store.ts +221 -17
- package/console/style.css +90 -0
- package/package.json +4 -5
- package/src/actor.ts +507 -54
- package/src/agent.ts +2 -2
- package/src/ambient.ts +2 -0
- package/src/assembly-checks.ts +165 -0
- package/src/assembly-tar.ts +494 -0
- package/src/assembly.ts +721 -0
- package/src/chart.ts +2050 -0
- package/src/charts.ts +720 -0
- package/src/config.ts +233 -22
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +313 -0
- package/src/custodian.ts +951 -0
- package/src/customize.ts +216 -25
- package/src/effect-checks.ts +557 -0
- package/src/effect.ts +905 -0
- package/src/effector-wire.ts +525 -0
- package/src/fingerprint.ts +4 -3
- package/src/gate.ts +8 -0
- package/src/git-clone.ts +65 -0
- package/src/git-push.ts +336 -0
- package/src/harness-client.ts +247 -25
- package/src/held-secrets.ts +643 -0
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +514 -119
- package/src/images.ts +58 -31
- package/src/index.ts +49 -4
- package/src/instance.ts +445 -59
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/model-keys.ts +92 -0
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +89 -7
- package/src/notices.ts +88 -0
- package/src/open-body.ts +1444 -0
- package/src/open-effect.ts +144 -0
- package/src/open.ts +4 -4
- package/src/parts.ts +530 -33
- package/src/party.ts +468 -0
- package/src/pick.ts +309 -0
- package/src/pool.ts +1 -1
- package/src/registration.ts +301 -17
- package/src/repo-fetch.ts +61 -86
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1841 -105
- package/src/sandbox-effect.ts +891 -0
- package/src/sandbox-effector.ts +111 -0
- package/src/sandbox-kube.ts +1719 -0
- package/src/sandbox-watch.ts +260 -0
- package/src/secret-placement.ts +494 -0
- package/src/server.ts +285 -89
- package/src/setup.ts +589 -70
- package/src/size.ts +342 -0
- package/src/snapshot-store.ts +64 -13
- package/src/stub-harness.ts +5 -0
- package/src/sync-client.ts +54 -0
- package/src/timers.ts +178 -0
- package/src/tokens.ts +44 -12
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1144
package/src/workspace.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// `workspace(body, { input, image, user, repos, spec })` (ADR-0012, ADR-0049, ADR-0051
|
|
2
|
-
// jr2-owned wrapper Machine that owns ONLY Sandbox lifecycle — provision the Sandbox
|
|
3
|
-
// STATIC `image`/`user`/`repos` options it carries) + attach one worktree per Repo Slot, run the
|
|
1
|
+
// `workspace(body, { input, image, user, repos, resources, spec })` (ADR-0012, ADR-0049, ADR-0051,
|
|
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) + attach one worktree per Repo Slot, run the
|
|
4
4
|
// author's body Machine inside it as the named slot `body`, with `{ workspace: { repos, branch } }`
|
|
5
5
|
// appended to its input (the mechanism-facing endpoint/sandbox are published ambiently
|
|
6
6
|
// — ADR-0016, ambient.ts), and destroy the Sandbox when the body reaches a final state. Teardown lives INSIDE the
|
|
@@ -14,14 +14,19 @@
|
|
|
14
14
|
// table): one cluster per orchestrator instance, so the port rides `RunHostOptions.sandbox`,
|
|
15
15
|
// never workflow code — `workspace` stays a plain static import (ADR-0011 doctrine) and unit
|
|
16
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
|
|
17
|
+
// (invoked actors re-execute from persisted input), so every operation must be idempotent.
|
|
18
18
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// `
|
|
19
|
+
// The lifecycle (ADR-0012, ADR-0064): `placing` writes the Sandbox and waits, with no deadline,
|
|
20
|
+
// for its pod to be scheduled; `provisioning` waits for Ready under a budget that starts there;
|
|
21
|
+
// `attaching` runs the Harness's attach; `running` runs the body; `teardown` destroys.
|
|
22
|
+
//
|
|
23
|
+
// Restore-reconcile (ADR-0012, ADR-0021): every state from `placing` to `running` co-invokes the
|
|
24
|
+
// lease, and in `running` the lease judges Continuity from the Orchestrator's watch for its
|
|
25
|
+
// Sandbox (ADR-0063). Invoked callback actors restart on every (re)entry — including snapshot
|
|
26
|
+
// restore — so after an orchestrator restart the watch's first list is the reconcile: present and
|
|
27
|
+
// not Lost → nothing (agent admissions re-attach — ADR-0016); gone or Lost → the pod-local clone
|
|
28
|
+
// and any unpushed commits are gone, so it delivers `workspace.lost` INTO the restored body (same
|
|
29
|
+
// channel as `agent.fault`) and the body's policy decides. Never silently re-provision.
|
|
25
30
|
|
|
26
31
|
import {
|
|
27
32
|
assign,
|
|
@@ -37,6 +42,7 @@ import {
|
|
|
37
42
|
import type { z } from "zod";
|
|
38
43
|
import { registerAmbientHandles, type AmbientHandles } from "./ambient.ts";
|
|
39
44
|
import {
|
|
45
|
+
actorRoute,
|
|
40
46
|
actorSlotPath,
|
|
41
47
|
assertRepoSlot,
|
|
42
48
|
attachSandboxParts,
|
|
@@ -49,16 +55,33 @@ import {
|
|
|
49
55
|
type JR2Repos,
|
|
50
56
|
type JR2Wrapper,
|
|
51
57
|
type RepoSlot,
|
|
58
|
+
type PlacementRoute,
|
|
52
59
|
type SandboxParts,
|
|
53
60
|
type WrapperActors,
|
|
54
61
|
} from "./parts.ts";
|
|
55
|
-
import { runBindingOf, type AnyActorSystem } from "./registration.ts";
|
|
62
|
+
import { actorPath, runBindingOf, type AnyActorSystem } from "./registration.ts";
|
|
63
|
+
import { assertSize, assertUserSeat, userSeatOf, type Size, type UserContainer } from "./size.ts";
|
|
56
64
|
import { attachInputSchema, inputSchemaOf, invokingMachine, type HostInjectedInput } from "./vocabulary.ts";
|
|
57
65
|
|
|
58
66
|
/** 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
|
|
60
|
-
*
|
|
61
|
-
const DEFAULT_LEASE_INTERVAL_MS = 5 * 60_000;
|
|
67
|
+
* few missed renewals in a row are survivable. It bounds only how long an orphan waits to be
|
|
68
|
+
* reaped: loss is the watch's news, within seconds (ADR-0021, ADR-0063). */
|
|
69
|
+
export const DEFAULT_LEASE_INTERVAL_MS = 5 * 60_000;
|
|
70
|
+
|
|
71
|
+
/** Each renewal lands at the interval ±20% (ADR-0021), so Leases restored together after an
|
|
72
|
+
* Orchestrator restart never renew in lockstep. */
|
|
73
|
+
const LEASE_JITTER = 0.2;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* When the lease renews next (ADR-0021), for a draw `random` in [0, 1]: every renewal at the
|
|
77
|
+
* interval ±20%, and the FIRST within a fifth of it — spread, like the rest, but soon, because a
|
|
78
|
+
* restore may follow an Orchestrator that was away for a while and the idle timeout has been
|
|
79
|
+
* running since its last stamp. Exported for the tests; the lease draws `Math.random()`.
|
|
80
|
+
*/
|
|
81
|
+
export function leaseDelay(interval: number, which: "first" | "next", random: number): number {
|
|
82
|
+
if (which === "first") return interval * LEASE_JITTER * random;
|
|
83
|
+
return interval * (1 - LEASE_JITTER + 2 * LEASE_JITTER * random);
|
|
84
|
+
}
|
|
62
85
|
|
|
63
86
|
/** What to attach, in workspace vocabulary only (ADR-0012 boundary): the one branch the body
|
|
64
87
|
* works on, the pod's work group, and the review sha. Derived PER RUN from the wrapper's input,
|
|
@@ -131,16 +154,52 @@ export type WorkspaceHandles<TSlots extends string> = {
|
|
|
131
154
|
export type Workspaced<TInput, TSlots extends string> = TInput & { workspace: WorkspaceHandles<TSlots> };
|
|
132
155
|
|
|
133
156
|
/**
|
|
134
|
-
* What
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
157
|
+
* What the watch says about a workspace (ADR-0021, ADR-0063): gone, or present — and, once the
|
|
158
|
+
* operator has judged its one pod gone or terminal, `lost` with the operator's reason and the pod's
|
|
159
|
+
* own words. A Sandbox has one pod for its life, so a Lost Sandbox never comes back; the operator
|
|
160
|
+
* judges identity because it knows which pod it created, and nothing here compares pod UIDs.
|
|
161
|
+
*/
|
|
162
|
+
export type Continuity = { present: false } | { present: true; lost?: SandboxLoss };
|
|
163
|
+
|
|
164
|
+
/** Why a Sandbox is Lost (ADR-0021): the pod's own reason (`Evicted`, `NodeShutdown`, ...) or the
|
|
165
|
+
* operator's (`PodDeleted`, `NodeLost`, `PodFailed`, `PodSucceeded`), and its message. */
|
|
166
|
+
export type SandboxLoss = { reason: string; message: string };
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Why a Sandbox with no node waits (ADR-0064): the scheduler found no node for it, or a
|
|
170
|
+
* ResourceQuota refused its pod. `message` is the scheduler's or the API server's own words, which
|
|
171
|
+
* can name nodes and taints — so it rides the authenticated status and the per-run feed only.
|
|
142
172
|
*/
|
|
143
|
-
export type
|
|
173
|
+
export type PlacingWait = { on: "node" | "quota"; message: string };
|
|
174
|
+
|
|
175
|
+
/** The Harness container's restarts on the Sandbox's one pod, and how it last ended — as the
|
|
176
|
+
* operator publishes them (ADR-0021, ADR-0063). The signal that ends the Turns a restart took. */
|
|
177
|
+
export type HarnessRestarts = {
|
|
178
|
+
restartCount: number;
|
|
179
|
+
lastTerminated?: { reason?: string; exitCode?: number; finishedAt?: string };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** What the port writes when a Workspace is placed (ADR-0012, ADR-0064). */
|
|
183
|
+
export type PlaceRequest = {
|
|
184
|
+
name: string;
|
|
185
|
+
runId: string;
|
|
186
|
+
workflow: string;
|
|
187
|
+
/** The placement route from the registered Machine to this `workspace()` (`actorRoute`). With
|
|
188
|
+
* `workflow`, it names the pod whose Held secrets and Sandbox Effector `jr2 up` placed (ADR-0059). */
|
|
189
|
+
route: PlacementRoute;
|
|
190
|
+
image?: string;
|
|
191
|
+
user?: string;
|
|
192
|
+
workGroup?: number;
|
|
193
|
+
repos: ProvisionedRepo[];
|
|
194
|
+
resources?: Size;
|
|
195
|
+
userResources?: Size;
|
|
196
|
+
/** The Sandbox Effector's image as the `workspace()` names it (ADR-0068): the port resolves it,
|
|
197
|
+
* and adds the container only when `jr2 up` placed an Effect there. Absent → the Sandbox Image. */
|
|
198
|
+
effector?: string;
|
|
199
|
+
/** The hash of the Assembly the run holds (ADR-0069). When it is not the current one, an image
|
|
200
|
+
* the map lacks fails naming it. Absent for a run no Assembly started. */
|
|
201
|
+
assembly?: string;
|
|
202
|
+
};
|
|
144
203
|
|
|
145
204
|
/** One Repo Slot as the port receives it at provision (ADR-0051): resolved to a Binding, and
|
|
146
205
|
* flagged when the run — not the Machine — chose the url, because that is what the credentials
|
|
@@ -149,27 +208,34 @@ export type ProvisionedRepo = { slot: string; url: string; ref?: string; perRun:
|
|
|
149
208
|
|
|
150
209
|
/**
|
|
151
210
|
* The Sandbox backend a host supplies (`RunHostOptions.sandbox`) — the seam between the
|
|
152
|
-
* workspace Machine and the cluster.
|
|
211
|
+
* workspace Machine and the cluster. Every operation MUST be idempotent: the invoking
|
|
153
212
|
* states re-run on snapshot restore (create-if-absent, attach-if-absent, delete-if-present).
|
|
154
213
|
*/
|
|
155
214
|
export interface SandboxPort {
|
|
156
|
-
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* (ADR-0037/0005/0049) — a `file:` context or a registry ref,
|
|
160
|
-
* context the last converge did not build fails here rather than
|
|
161
|
-
* wrapper's slots, resolved, in declaration order (ADR-0051): the
|
|
162
|
-
* CR so the cluster mounts its cache, and refuses a per-run url no
|
|
163
|
-
* admits. `workGroup` is the pod's `fsGroup`; the port owns the default.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Write the Sandbox (its token Secret, then the CR, labeled with its run for `jr2 ls`) and wait,
|
|
217
|
+
* with NO deadline, until the operator says its pod is scheduled (ADR-0064). `image`/`user` are
|
|
218
|
+
* the wrapper's static image options (ADR-0037/0005/0049) — a `file:` context or a registry ref,
|
|
219
|
+
* the port resolves both, and a context the last converge did not build fails here rather than
|
|
220
|
+
* converge-time. `repos` are the wrapper's slots, resolved, in declaration order (ADR-0051): the
|
|
221
|
+
* port names each Repo on the CR so the cluster mounts its cache, and refuses a per-run url no
|
|
222
|
+
* `git.credentials` entry admits. `workGroup` is the pod's `fsGroup`; the port owns the default.
|
|
223
|
+
* `resources` is the Size the Machine states and `userResources` the User Container's split of it
|
|
224
|
+
* (ADR-0060), both as stated — the port resolves the rest of the chain and splits the Size inside
|
|
225
|
+
* the pod.
|
|
226
|
+
*
|
|
227
|
+
* While the pod has no node, `onWait` hears why, each time the reason changes. A Sandbox that is
|
|
228
|
+
* Lost, or deleted, before it is placed rejects. `signal` ends the wait: the promise rejects once
|
|
229
|
+
* any write in flight has settled, so a caller that deletes after it never races the apply.
|
|
230
|
+
*/
|
|
231
|
+
place(req: PlaceRequest, opts: { onWait: (wait: PlacingWait) => void; signal?: AbortSignal }): Promise<void>;
|
|
232
|
+
/**
|
|
233
|
+
* Wait for the placed Sandbox's `phase: Ready` under the port's budget, which starts NOW — the
|
|
234
|
+
* pod is scheduled, so it measures only what jr2 controls: the image pull, the Harness start, the
|
|
235
|
+
* Repos (ADR-0064). Resolves with the Harness endpoint the orchestrator can reach. Lost, or gone,
|
|
236
|
+
* rejects naming why.
|
|
237
|
+
*/
|
|
238
|
+
provision(name: string): Promise<{ endpoint: string }>;
|
|
173
239
|
/** Post-Ready attach (ADR-0004): per slot, `git clone --shared` off the node's read-only
|
|
174
240
|
* cache (the `default/` checkout), then a branch worktree sibling — and, with `spec.reviewSha`, the detached
|
|
175
241
|
* review worktree (ADR-0028). Resolves with the worktree paths by slot; `stale` names the slots
|
|
@@ -185,22 +251,53 @@ export interface SandboxPort {
|
|
|
185
251
|
stale?: Record<string, string>;
|
|
186
252
|
}>;
|
|
187
253
|
/**
|
|
188
|
-
* Renew this workspace's keepalive lease
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
|
|
194
|
-
|
|
254
|
+
* Renew this workspace's keepalive lease — a WRITE, and only that (ADR-0021, ADR-0063). Nothing
|
|
255
|
+
* in the cluster represents a run (ADR-0001), so the lease is the Orchestrator's assertion
|
|
256
|
+
* that lets the operator reap an orphan and no one else. It learns nothing: Continuity is the
|
|
257
|
+
* watch's. Idempotent, called on a jittered timer; a failure rejects and the caller shrugs —
|
|
258
|
+
* the next renewal is minutes away and the idle timeout is thirty.
|
|
259
|
+
*/
|
|
260
|
+
renew(name: string): Promise<void>;
|
|
261
|
+
/**
|
|
262
|
+
* Hear this workspace's Continuity from the watch (ADR-0021, ADR-0063): once the watch has
|
|
263
|
+
* listed, what it holds now, then again on every change the cluster reports — a Sandbox gone, or
|
|
264
|
+
* one the operator has made Lost. A dropped watch reports NOTHING: unknown is never loss, and the
|
|
265
|
+
* listener never hears a fabricated `{present: false}`. Returns the unsubscribe.
|
|
266
|
+
*/
|
|
267
|
+
continuity(name: string, listener: (seen: Continuity) => void): () => void;
|
|
268
|
+
/**
|
|
269
|
+
* Hear the Harness container's restarts on this Sandbox's pod (ADR-0021): once the watch has
|
|
270
|
+
* listed, then on every change. A Turn records the count at its admission, and a count above it
|
|
271
|
+
* means the conversation it waits on is gone. Silent for a name the watch does not hold — the
|
|
272
|
+
* Instance Harness is a StatefulSet, not a Sandbox. Returns the unsubscribe.
|
|
273
|
+
*/
|
|
274
|
+
harnessRestarts(name: string, listener: (seen: HarnessRestarts) => void): () => void;
|
|
275
|
+
/**
|
|
276
|
+
* Name a memory kill (ADR-0061): when the Harness container of this Sandbox last ended
|
|
277
|
+
* `OOMKilled` at or after `since`, the kill — its fault reason, starting with the fixed prefix
|
|
278
|
+
* `memory limit`, and the data the next Turn's notice is made of (ADR-0062) — else undefined.
|
|
279
|
+
* Asked when a Turn faults on a lost conversation; it may wait a moment for the operator's word,
|
|
280
|
+
* which can trail the Harness's restart. A backend that cannot see the container answers
|
|
281
|
+
* undefined, and every lost conversation stays lost — never an optional method (ADR-0021).
|
|
195
282
|
*/
|
|
196
|
-
|
|
283
|
+
memoryFault(name: string, since: Date): Promise<MemoryKill | undefined>;
|
|
197
284
|
/** Delete the Sandbox CR. Absent is success. */
|
|
198
285
|
destroy(name: string): Promise<void>;
|
|
199
286
|
/** How often to renew. Must be well inside the backend's idle-timeout, since a lapsed lease is
|
|
200
|
-
* what lets the operator reap.
|
|
287
|
+
* what lets the operator reap. */
|
|
201
288
|
readonly leaseIntervalMs?: number;
|
|
202
289
|
}
|
|
203
290
|
|
|
291
|
+
/** A kernel memory kill of a Sandbox's Harness container (ADR-0061, layer 5). */
|
|
292
|
+
export type MemoryKill = {
|
|
293
|
+
/** The fault reason, starting with the fixed prefix `memory limit`. */
|
|
294
|
+
reason: string;
|
|
295
|
+
/** The limit the kernel enforced, as the Sandbox states it (`1920Mi`). */
|
|
296
|
+
limit?: string;
|
|
297
|
+
/** When the container ended — which kill this was, so every Turn it ended names the same one. */
|
|
298
|
+
at?: string;
|
|
299
|
+
};
|
|
300
|
+
|
|
204
301
|
/** Resolve the host's Sandbox backend, failing with a pointed message on a host without one. */
|
|
205
302
|
export function sandboxOf(system: AnyActorSystem): SandboxPort {
|
|
206
303
|
const port = runBindingOf(system).sandbox;
|
|
@@ -215,7 +312,7 @@ export function sandboxOf(system: AnyActorSystem): SandboxPort {
|
|
|
215
312
|
|
|
216
313
|
/**
|
|
217
314
|
* 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
|
|
315
|
+
* the wrapper's actor id (both stable across restore — that is what lets the lease
|
|
219
316
|
* and a re-run provision find the SAME CR), collision-proofed by a content suffix.
|
|
220
317
|
*/
|
|
221
318
|
export function workspaceName(runId: string, wsId: string): string {
|
|
@@ -239,6 +336,17 @@ type MechanismHandles = AmbientHandles;
|
|
|
239
336
|
* mapper is not re-evaluated against an input that may since have been re-parsed. */
|
|
240
337
|
export type ResolvedBinding = { url: string; ref?: string; perRun: boolean };
|
|
241
338
|
|
|
339
|
+
/**
|
|
340
|
+
* A Workspace's wait for capacity as its context holds it (ADR-0064): the reason as the port last
|
|
341
|
+
* heard it, and when the wait began. `since` survives a changed reason and a restart — it is how
|
|
342
|
+
* long the Workspace has waited, which is what the feed's closing line reports.
|
|
343
|
+
*/
|
|
344
|
+
export type WorkspaceWait = PlacingWait & { since: string };
|
|
345
|
+
|
|
346
|
+
/** The context key the wait rides under — what `RunStatus.waiting` reads off a Workspace's
|
|
347
|
+
* snapshot, live or persisted. */
|
|
348
|
+
export const WORKSPACE_WAIT_KEY = "wait";
|
|
349
|
+
|
|
242
350
|
type WsContext = {
|
|
243
351
|
/** The wrapper's own input, passed through to the body untouched (plus `workspace`). */
|
|
244
352
|
runInput: Record<string, unknown>;
|
|
@@ -249,10 +357,9 @@ type WsContext = {
|
|
|
249
357
|
/** Every slot's resolved Binding, by slot, in declaration order — assigned at provision. */
|
|
250
358
|
bindings?: Record<string, ResolvedBinding>;
|
|
251
359
|
endpoint?: string;
|
|
252
|
-
/**
|
|
253
|
-
*
|
|
254
|
-
|
|
255
|
-
identity?: string;
|
|
360
|
+
/** Why the Sandbox waits for a node, while `placing` (ADR-0064) — persisted, so a restart keeps
|
|
361
|
+
* when the wait began and `RunStatus.waiting` reads it off the snapshot. Cleared once placed. */
|
|
362
|
+
wait?: WorkspaceWait;
|
|
256
363
|
/** Persisted in context so the registrar can re-publish them on restore. */
|
|
257
364
|
handles?: MechanismHandles;
|
|
258
365
|
output?: unknown;
|
|
@@ -281,7 +388,7 @@ export type WorkspaceMachine<TInput, TOutput, TBody extends AnyStateMachine, TSl
|
|
|
281
388
|
any,
|
|
282
389
|
any,
|
|
283
390
|
any,
|
|
284
|
-
WrapperActors<"body", TBody, "provision" | "attach" | "registrar" | "lease" | "destroy">,
|
|
391
|
+
WrapperActors<"body", TBody, "place" | "provision" | "attach" | "registrar" | "lease" | "destroy">,
|
|
285
392
|
any,
|
|
286
393
|
any,
|
|
287
394
|
any,
|
|
@@ -389,11 +496,21 @@ type BodyAcceptsSlots<TBody extends AnyStateMachine, TSlots extends string> =
|
|
|
389
496
|
export type SandboxOptions<TSlots extends string, TInput = unknown> = {
|
|
390
497
|
/** The Sandbox Image. Absent → the Instance's `images/default`, then the stock Harness. */
|
|
391
498
|
image?: string;
|
|
392
|
-
/** The User Container
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
* not forwarded.
|
|
396
|
-
|
|
499
|
+
/** The User Container (ADR-0005): its image, or `{ image, resources }` when the Machine states
|
|
500
|
+
* the seat's split of the Size (ADR-0060) — the widening ADR-0005 foresaw. Absent → the pod has
|
|
501
|
+
* no third container: there is no default, because the seat's whole identity is "what jr2 does
|
|
502
|
+
* not own" and jr2 has nothing to put there. Env and ports are deliberately not forwarded. With
|
|
503
|
+
* no split stated, the seat has no limit of its own and shares the pod's budget. */
|
|
504
|
+
user?: string | UserContainer;
|
|
505
|
+
/**
|
|
506
|
+
* The Size (ADR-0060): the ceiling for the whole Sandbox, `limits.memory` and `limits.cpu` and
|
|
507
|
+
* nothing else — Agent Substrate's template size, so the Workspace moves there unchanged. A fact
|
|
508
|
+
* of the Machine, like its image: the author knows the toolchain, the runner, the worker count.
|
|
509
|
+
* jr2 reserves all of it (every request equals its limit) and enforces the CPU limit. Absent, or
|
|
510
|
+
* a field left out → the Instance's `sandbox.resources`, then the kit default (2Gi, 1 cpu). A
|
|
511
|
+
* composer retunes it with `customize(machine, { resources })`.
|
|
512
|
+
*/
|
|
513
|
+
resources?: Size;
|
|
397
514
|
/**
|
|
398
515
|
* The Repo Slots (ADR-0051), keyed by the Machine's own word for each — the key of the body's
|
|
399
516
|
* `workspace.repos` handles and the directory under `/work`. The handles keep this map's order,
|
|
@@ -407,6 +524,22 @@ export type SandboxOptions<TSlots extends string, TInput = unknown> = {
|
|
|
407
524
|
* `task`, whose first slot is the one the coder edits). Under it `TSlots` is `string`, and the body's handles must say so.
|
|
408
525
|
*/
|
|
409
526
|
repos: Record<TSlots, RepoSlot<TInput>> | typeof open;
|
|
527
|
+
/**
|
|
528
|
+
* The Held secrets this Workspace's pod gets (ADR-0059): names of `jr2.config.ts`'s `secrets`
|
|
529
|
+
* list. Its Custodian holds each one, and the Harness container sees its Stand-in. Naming is
|
|
530
|
+
* placement: a pod gets the Held secrets something in it names — this list, and the model keys
|
|
531
|
+
* of the Agents whose Turns run here, which their providers claim without being listed.
|
|
532
|
+
*/
|
|
533
|
+
secrets?: readonly string[];
|
|
534
|
+
/**
|
|
535
|
+
* The Sandbox Effector's image (ADR-0068, ADR-0005): the container that runs this Workspace's
|
|
536
|
+
* `push`, file-body `http` and credentialed `command` Effects, with the secrets they name. Absent → the
|
|
537
|
+
* Sandbox Image, whose floor already holds `git` (ADR-0037). Name one for the tools and the
|
|
538
|
+
* credentials kept out of the Agent's image (`terraform`, `aws`). A `file:` context or a
|
|
539
|
+
* registry ref, resolved like the User Container's image; it runs as its own `USER`. The
|
|
540
|
+
* container exists only when an Effect below this `workspace()` is placed there.
|
|
541
|
+
*/
|
|
542
|
+
effector?: { image: string };
|
|
410
543
|
};
|
|
411
544
|
|
|
412
545
|
/**
|
|
@@ -477,16 +610,18 @@ export function workspace(
|
|
|
477
610
|
}
|
|
478
611
|
// Static, so checkable NOW rather than at the first provision — an empty or non-string image is
|
|
479
612
|
// the same derives-from-a-typo bug `assertSpec` catches for the spec, one build earlier.
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
);
|
|
488
|
-
}
|
|
613
|
+
const image = options.image;
|
|
614
|
+
if (image !== undefined && (typeof image !== "string" || !image)) {
|
|
615
|
+
throw new Error(
|
|
616
|
+
`workspace(): \`image\` must be a non-empty string (got ${JSON.stringify(image)}) — either a \`file:\` ` +
|
|
617
|
+
'URL to a docker context this module ships (`import.meta.resolve("./image")`) or a registry ref ' +
|
|
618
|
+
"(ADR-0037).",
|
|
619
|
+
);
|
|
489
620
|
}
|
|
621
|
+
if (options.user !== undefined) assertUserSeat("workspace()", options.user);
|
|
622
|
+
// The Size, refused by field where it is written (ADR-0060): only the two limits. A request, or
|
|
623
|
+
// any key Substrate would refuse, is a dependency the move to it would drop.
|
|
624
|
+
if (options.resources !== undefined) assertSize("workspace(): resources", options.resources);
|
|
490
625
|
// The slots, checked NOW for the same reason (ADR-0051): every value is one of the three forms,
|
|
491
626
|
// every key is a directory name, and there is at least one — a Workspace exists to work on a
|
|
492
627
|
// repository, and a wrapper with no slot would attach nothing and hand the body no checkout.
|
|
@@ -502,6 +637,33 @@ export function workspace(
|
|
|
502
637
|
}
|
|
503
638
|
for (const [slot, value] of Object.entries(repos)) assertRepoSlot("workspace()", slot, value);
|
|
504
639
|
}
|
|
640
|
+
// The Held secrets it names (ADR-0059), checked NOW: each a name, each once. Whether the list
|
|
641
|
+
// holds the name is `jr2 up`'s to say — the config is not here.
|
|
642
|
+
const secrets = options.secrets;
|
|
643
|
+
if (secrets !== undefined) {
|
|
644
|
+
const names = Array.isArray(secrets) ? (secrets as unknown[]) : undefined;
|
|
645
|
+
const bad = names?.find((n) => typeof n !== "string" || !n);
|
|
646
|
+
if (names === undefined || bad !== undefined || new Set(names).size !== names.length) {
|
|
647
|
+
throw new Error(
|
|
648
|
+
`workspace(): \`secrets\` must be a list of distinct names from jr2.config.ts's \`secrets\` list (got ` +
|
|
649
|
+
`${JSON.stringify(secrets)}) — secrets: ["JIRA_TOKEN"] (ADR-0059).`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const effector = options.effector;
|
|
654
|
+
if (
|
|
655
|
+
effector !== undefined &&
|
|
656
|
+
(typeof effector !== "object" ||
|
|
657
|
+
effector === null ||
|
|
658
|
+
typeof effector.image !== "string" ||
|
|
659
|
+
!effector.image ||
|
|
660
|
+
Object.keys(effector).some((k) => k !== "image"))
|
|
661
|
+
) {
|
|
662
|
+
throw new Error(
|
|
663
|
+
`workspace(): \`effector\` is { image } — a \`file:\` URL to a docker context or a registry ref (got ` +
|
|
664
|
+
`${JSON.stringify(effector)}) — effector: { image: import.meta.resolve("./effector") } (ADR-0068).`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
505
667
|
const wrapper = buildWorkspaceMachine(body, options.spec);
|
|
506
668
|
// The wrapper is TRANSPARENT to its body (ADR-0049): `customize(machine, { agents })` on a
|
|
507
669
|
// Workspace means the Machine inside, so the composer never spells `body` and never has to know
|
|
@@ -516,6 +678,9 @@ export function workspace(
|
|
|
516
678
|
...(options.image !== undefined ? { image: options.image } : {}),
|
|
517
679
|
...(options.user !== undefined ? { user: options.user } : {}),
|
|
518
680
|
repos: repos === open ? open : { ...repos },
|
|
681
|
+
...(options.resources !== undefined ? { resources: options.resources } : {}),
|
|
682
|
+
...(secrets !== undefined ? { secrets: [...secrets] } : {}),
|
|
683
|
+
...(effector !== undefined ? { effector: { image: effector.image } } : {}),
|
|
519
684
|
});
|
|
520
685
|
// The body's vocabulary stays the BODY's (ADR-0011, ADR-0049): the wrapper declares no events
|
|
521
686
|
// of its own and merges none, because the actors that use the body's names resolve against the
|
|
@@ -624,10 +789,30 @@ function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot:
|
|
|
624
789
|
}
|
|
625
790
|
|
|
626
791
|
function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any }) => WorkspaceSpec): AnyStateMachine {
|
|
627
|
-
|
|
628
|
-
|
|
792
|
+
/**
|
|
793
|
+
* `placing` (ADR-0064): write the Sandbox and wait, with no deadline, for its pod to be
|
|
794
|
+
* scheduled. jr2 cannot tell "full now" from "never fits", so a bound is the Machine's own
|
|
795
|
+
* `after` on the state that holds the Workspace.
|
|
796
|
+
*
|
|
797
|
+
* The wait is said, never sent to the body: each reason the port hears is assigned into this
|
|
798
|
+
* wrapper's context (`workspace.waiting`, where `RunStatus.waiting` reads it), and the feed gets
|
|
799
|
+
* two lines — the wait starting, with its reason, and the wait ending ("placed after …"). A
|
|
800
|
+
* changed reason updates the context and adds no line. A restart re-enters here: the apply is
|
|
801
|
+
* idempotent, the watch's first list answers at once, and `since` is read back off the
|
|
802
|
+
* wrapper's context, so neither line is said twice.
|
|
803
|
+
*
|
|
804
|
+
* Delete what never ran: stopped before its pod has a node — a parent `after`, a cancelled run, a
|
|
805
|
+
* run faulted elsewhere — this Sandbox is deleted at once, because nothing on it can be inspected
|
|
806
|
+
* and left alone it could take a node later for nobody. xstate's stop is synchronous, so the
|
|
807
|
+
* delete rides the abort, after any write in flight has settled. A wait that fails on its own —
|
|
808
|
+
* the Sandbox Lost before its node, a write that half landed — is the same end: the delete comes
|
|
809
|
+
* before the fault, and the fault carries the reason. The host stopping the run for a restart is
|
|
810
|
+
* not an end: the restored run re-enters `placing` and keeps its place.
|
|
811
|
+
*/
|
|
812
|
+
const place = fromPromise<
|
|
813
|
+
{ bindings: Record<string, ResolvedBinding> },
|
|
629
814
|
{ wsId: string; spec: WorkspaceSpec; runInput: unknown }
|
|
630
|
-
>(async ({ input, self, system }) => {
|
|
815
|
+
>(async ({ input, self, system, signal }) => {
|
|
631
816
|
assertSpec(input.spec); // before the port: a bad spec must never cost a pod
|
|
632
817
|
const binding = runBindingOf(system);
|
|
633
818
|
// The images and the slots come off the WRAPPER, at invoke time, not out of context and not
|
|
@@ -637,31 +822,118 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
637
822
|
// content-addressed tag, which would outlive the image it names. The RESOLVED bindings are
|
|
638
823
|
// persisted, because a per-run mapper's answer is this run's fact.
|
|
639
824
|
const parts = sandboxPartsOf(invokingMachine(self));
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
825
|
+
const user = userSeatOf(parts.user);
|
|
826
|
+
const path = actorSlotPath(self._parent);
|
|
827
|
+
// Read before the port: a Workspace no walk can see placed nothing, and is refused with the slot form.
|
|
828
|
+
const route = actorRoute(self._parent);
|
|
829
|
+
const bindings = resolveBindings({ workflow: binding.workflow, path }, parts.repos, input.runInput);
|
|
830
|
+
const port = sandboxOf(system);
|
|
831
|
+
const name = workspaceName(binding.runId, input.wsId);
|
|
832
|
+
const wrapper = self._parent;
|
|
833
|
+
const child = wrapper ? actorPath(wrapper).join("/") : "";
|
|
834
|
+
// A wait opened before a restart is the same wait: its start is in the wrapper's persisted
|
|
835
|
+
// context, never in this actor's input, which a restore replays as it was first invoked.
|
|
836
|
+
const opened = (): string | undefined =>
|
|
837
|
+
((wrapper?.getSnapshot() as { context?: WsContext } | undefined)?.context?.wait as WorkspaceWait | undefined)
|
|
838
|
+
?.since;
|
|
839
|
+
let since: string | undefined;
|
|
840
|
+
/** When THIS actor opened the wait, on the monotonic clock: `after` is a duration, and two
|
|
841
|
+
* wall-clock reads go negative when the host's clock steps back between them. */
|
|
842
|
+
let openedAt: number | undefined;
|
|
843
|
+
let heard: PlacingWait | undefined;
|
|
844
|
+
const onWait = (wait: PlacingWait) => {
|
|
845
|
+
if (signal.aborted || (heard?.on === wait.on && heard.message === wait.message)) return;
|
|
846
|
+
heard = wait;
|
|
847
|
+
since ??= opened();
|
|
848
|
+
if (since === undefined) {
|
|
849
|
+
since = new Date().toISOString();
|
|
850
|
+
openedAt = performance.now();
|
|
851
|
+
binding.marker?.({ kind: "placing", child, on: wait.on, message: wait.message, since });
|
|
852
|
+
}
|
|
853
|
+
wrapper?.send({ type: "workspace.waiting", wait: { ...wait, since } });
|
|
854
|
+
};
|
|
855
|
+
const placed = port.place(
|
|
856
|
+
{
|
|
857
|
+
name,
|
|
858
|
+
runId: binding.runId,
|
|
859
|
+
workflow: binding.workflow,
|
|
860
|
+
route,
|
|
861
|
+
// The image strings straight through (ADR-0037/0005) — the port owns resolution, and the
|
|
862
|
+
// work group's default (ADR-0005 puts it in pod composition, where the pod is built).
|
|
863
|
+
...(parts.image !== undefined ? { image: parts.image } : {}),
|
|
864
|
+
...(user.image !== undefined ? { user: user.image } : {}),
|
|
865
|
+
...(input.spec.workGroup !== undefined ? { workGroup: input.spec.workGroup } : {}),
|
|
866
|
+
repos: Object.entries(bindings).map(([slot, b]) => ({ slot, ...b })),
|
|
867
|
+
// The Size as the Machine states it, re-read here like the images (ADR-0060): a redeploy
|
|
868
|
+
// that retuned it reaches the next provision, and no snapshot holds it.
|
|
869
|
+
...(parts.resources !== undefined ? { resources: parts.resources } : {}),
|
|
870
|
+
...(user.resources !== undefined ? { userResources: user.resources } : {}),
|
|
871
|
+
...(parts.effector !== undefined ? { effector: parts.effector.image } : {}),
|
|
872
|
+
...(binding.assembly !== undefined ? { assembly: binding.assembly } : {}),
|
|
873
|
+
},
|
|
874
|
+
{ onWait, signal },
|
|
644
875
|
);
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
876
|
+
signal.addEventListener(
|
|
877
|
+
"abort",
|
|
878
|
+
() => {
|
|
879
|
+
if (binding.hostStopping) return;
|
|
880
|
+
void placed
|
|
881
|
+
.catch(() => {})
|
|
882
|
+
.then(() => port.destroy(name))
|
|
883
|
+
.catch(() => {}); // a delete that fails leaves the CR to the idle GC, as ever
|
|
884
|
+
},
|
|
885
|
+
{ once: true },
|
|
886
|
+
);
|
|
887
|
+
try {
|
|
888
|
+
await placed;
|
|
889
|
+
} catch (err) {
|
|
890
|
+
// An abort is the listener's to delete; a failure of the wait itself is deleted here.
|
|
891
|
+
if (!signal.aborted && !binding.hostStopping) await port.destroy(name).catch(() => {});
|
|
892
|
+
throw err;
|
|
893
|
+
}
|
|
894
|
+
since ??= opened();
|
|
895
|
+
if (since !== undefined) {
|
|
896
|
+
// A wait opened before a restart has only its stored wall-clock start, so its duration is two
|
|
897
|
+
// wall-clock reads; it is clamped at zero, as a clock that stepped back since the start would
|
|
898
|
+
// make it negative.
|
|
899
|
+
const after =
|
|
900
|
+
openedAt !== undefined ? Math.round(performance.now() - openedAt) : Math.max(0, Date.now() - Date.parse(since));
|
|
901
|
+
binding.marker?.({ kind: "placed", child, since, after });
|
|
902
|
+
}
|
|
903
|
+
return { bindings };
|
|
657
904
|
});
|
|
658
905
|
|
|
906
|
+
/** `provisioning`: the placed pod's Ready, under the port's budget, which starts here (ADR-0064). */
|
|
907
|
+
const provision = fromPromise<{ endpoint: string }, { wsId: string }>(async ({ input, system }) =>
|
|
908
|
+
sandboxOf(system).provision(workspaceName(runBindingOf(system).runId, input.wsId)),
|
|
909
|
+
);
|
|
910
|
+
|
|
659
911
|
const attach = fromPromise<
|
|
660
912
|
{ repos: Record<string, string>; review?: Record<string, string>; stale?: Record<string, string> },
|
|
661
913
|
{ wsId: string; spec: WorkspaceSpec; bindings: Record<string, ResolvedBinding> }
|
|
662
|
-
>(async ({ input, system }) => {
|
|
914
|
+
>(async ({ input, system, signal }) => {
|
|
915
|
+
const port = sandboxOf(system);
|
|
663
916
|
const name = workspaceName(runBindingOf(system).runId, input.wsId);
|
|
664
|
-
|
|
917
|
+
// The attach is loss-aware (ADR-0021): a Sandbox gone or Lost under it fails the attach at
|
|
918
|
+
// once and by name, as it fails the placing and the provisioning — never a transport error
|
|
919
|
+
// one attach window later. Only the cluster's word: a dropped watch says nothing.
|
|
920
|
+
let unsubscribe = (): void => {};
|
|
921
|
+
const loss = new Promise<never>((_, reject) => {
|
|
922
|
+
unsubscribe = port.continuity(name, (seen) => {
|
|
923
|
+
const lost = !seen.present ? GONE : seen.lost;
|
|
924
|
+
if (lost === undefined) return;
|
|
925
|
+
const what = seen.present ? "is Lost" : "is gone";
|
|
926
|
+
reject(new Error(`Sandbox "${name}" ${what} while it attached: ${lost.reason}: ${lost.message} (ADR-0021)`));
|
|
927
|
+
});
|
|
928
|
+
});
|
|
929
|
+
loss.catch(() => {});
|
|
930
|
+
signal.addEventListener("abort", () => unsubscribe(), { once: true });
|
|
931
|
+
let out: Awaited<ReturnType<typeof port.attach>>;
|
|
932
|
+
try {
|
|
933
|
+
out = await Promise.race([port.attach({ name, spec: input.spec, repos: attachedRepos(input.bindings) }), loss]);
|
|
934
|
+
} finally {
|
|
935
|
+
unsubscribe();
|
|
936
|
+
}
|
|
665
937
|
// Announced, never persisted (ADR-0051): a stale cache is a degraded attach the run proceeds
|
|
666
938
|
// through on the objects the node holds. The pod cannot heal it — the worktree's `origin`
|
|
667
939
|
// fetches from the cache, not the remote (ADR-0005: the pod holds no credential) — so stale
|
|
@@ -696,65 +968,92 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
696
968
|
});
|
|
697
969
|
|
|
698
970
|
/**
|
|
699
|
-
* The lease (ADR-0021). One actor owns the
|
|
700
|
-
* it
|
|
701
|
-
* saying "still mine" or the operator's idle GC reaps — ADR-0001) and
|
|
702
|
-
*
|
|
971
|
+
* The lease (ADR-0021). One actor owns both halves of the exchange with the cluster for one
|
|
972
|
+
* workspace: it ASSERTS liveness with a write (nothing in the cluster represents a run, so the
|
|
973
|
+
* Orchestrator must keep saying "still mine" or the operator's idle GC reaps — ADR-0001), and it
|
|
974
|
+
* LISTENS to the watch for whether what the body attached to is still there (ADR-0063).
|
|
703
975
|
*
|
|
704
|
-
* Being an INVOKED actor is the whole design.
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
976
|
+
* Being an INVOKED actor is the whole design. It is invoked beside the work of every state from
|
|
977
|
+
* `placing` to `running`, so the renewal covers the Sandbox from its write to its teardown — a
|
|
978
|
+
* Sandbox that waits for capacity is never reaped as abandoned (ADR-0064) — and there is one at
|
|
979
|
+
* a time, because a state's invocations stop when it exits. It re-invokes on snapshot restore (so
|
|
980
|
+
* a restart reconciles for free: the watch's first list is the first thing it hears), and it
|
|
981
|
+
* stops on every exit — body final, run stopped, run faulted. That last one is why there is no
|
|
982
|
+
* `release()`: a faulted run stops its actors, the lease stops with them, and the abandoned pod
|
|
983
|
+
* ages out of the idle timeout on its own.
|
|
709
984
|
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
985
|
+
* Continuity is JUDGED only in `running`, once there is an attached pod: before that, a Sandbox
|
|
986
|
+
* that is Lost or gone fails the placing, the provisioning or the attach, by name, on its own. The two halves
|
|
987
|
+
* share no call: loss arrives within seconds of the cluster saying so; the renewal is a merge
|
|
988
|
+
* patch every interval ±20%, whose only job is to keep the Sandbox from being reaped.
|
|
713
989
|
*/
|
|
714
|
-
const lease = fromCallback<{ type: string }, { wsId: string;
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
};
|
|
990
|
+
const lease = fromCallback<{ type: string }, { wsId: string; judging: boolean }>(
|
|
991
|
+
({ input, self, system, sendBack }) => {
|
|
992
|
+
const port = sandboxOf(system);
|
|
993
|
+
const name = workspaceName(runBindingOf(system).runId, input.wsId);
|
|
994
|
+
let stopped = false;
|
|
995
|
+
let lost = false;
|
|
737
996
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
997
|
+
const unsubscribe = input.judging
|
|
998
|
+
? port.continuity(name, (seen) => {
|
|
999
|
+
if (stopped || lost) return;
|
|
1000
|
+
// Two ways to lose a workspace, one event. Gone: reaped, deleted, namespace cleared. Lost:
|
|
1001
|
+
// the CR survived but its one pod did not — evicted, its node lost or shut down — so
|
|
1002
|
+
// every name still resolves over an empty `work` volume, and the operator never gives it
|
|
1003
|
+
// a second pod. Re-provisioning either silently would resume into an inconsistent world
|
|
1004
|
+
// — the body decides (ADR-0012).
|
|
1005
|
+
const loss: SandboxLoss | undefined = !seen.present ? GONE : seen.lost;
|
|
1006
|
+
if (loss !== undefined) {
|
|
1007
|
+
lost = true; // once: the body has been told, and the workspace does not come back
|
|
1008
|
+
sendBack({ type: "workspace.lost", reason: loss.reason, message: loss.message });
|
|
1009
|
+
}
|
|
1010
|
+
})
|
|
1011
|
+
: () => {};
|
|
1012
|
+
|
|
1013
|
+
// The assertion, jittered from the first renewal on (see `leaseDelay`).
|
|
1014
|
+
const interval = port.leaseIntervalMs ?? DEFAULT_LEASE_INTERVAL_MS;
|
|
1015
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
1016
|
+
const schedule = (ms: number) => {
|
|
1017
|
+
timer = setTimeout(() => {
|
|
1018
|
+
// A Workspace that ended in error stopped processing without stopping its children (xstate
|
|
1019
|
+
// does not), and a parent that handled the error only stops what still processes: the
|
|
1020
|
+
// lease asks, and asserts nothing for a Workspace that is over.
|
|
1021
|
+
if (stopped || self._parent?.getSnapshot().status !== "active") return;
|
|
1022
|
+
// A failed renewal is unknown, never lost: the next one is an interval away, well inside
|
|
1023
|
+
// the idle timeout, and loss is the watch's to report. Before the CR's write lands it is a
|
|
1024
|
+
// 404, and the same shrug.
|
|
1025
|
+
port.renew(name).catch(() => {});
|
|
1026
|
+
schedule(leaseDelay(interval, "next", Math.random()));
|
|
1027
|
+
}, ms);
|
|
1028
|
+
timer.unref?.(); // a lease never holds the process open; it matters only while the run runs
|
|
1029
|
+
};
|
|
1030
|
+
schedule(leaseDelay(interval, "first", Math.random()));
|
|
1031
|
+
return () => {
|
|
1032
|
+
stopped = true;
|
|
1033
|
+
clearTimeout(timer);
|
|
1034
|
+
unsubscribe();
|
|
1035
|
+
};
|
|
1036
|
+
},
|
|
1037
|
+
);
|
|
746
1038
|
|
|
747
1039
|
const destroy = fromPromise<void, { wsId: string }>(async ({ input, system }) =>
|
|
748
1040
|
sandboxOf(system).destroy(workspaceName(runBindingOf(system).runId, input.wsId)),
|
|
749
1041
|
);
|
|
750
1042
|
|
|
1043
|
+
/** The lease beside a state's own work — renewing everywhere, judging in `running` alone. */
|
|
1044
|
+
const leaseInvoke = (judging: boolean) => ({
|
|
1045
|
+
id: "lease",
|
|
1046
|
+
src: "lease" as const,
|
|
1047
|
+
input: ({ context }: { context: unknown }) => ({ wsId: (context as WsContext).wsId, judging }),
|
|
1048
|
+
});
|
|
1049
|
+
|
|
751
1050
|
// Every actor this wrapper runs is a NAMED SLOT (ADR-0049), the body first among them: a Machine
|
|
752
1051
|
// 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
|
|
754
|
-
// provision, attach, registrar, lease, destroy — are named for the same price, and the
|
|
755
|
-
//
|
|
1052
|
+
// the Console's join key and the `jr2 up` parts walk all reach it by. The mechanism's own six —
|
|
1053
|
+
// place, provision, attach, registrar, lease, destroy — are named for the same price, and the
|
|
1054
|
+
// Console shows what each state is doing instead of "inline".
|
|
756
1055
|
return setup({
|
|
757
|
-
actors: { body, provision, attach, registrar, lease, destroy },
|
|
1056
|
+
actors: { body, place, provision, attach, registrar, lease, destroy },
|
|
758
1057
|
}).createMachine({
|
|
759
1058
|
id: "workspace",
|
|
760
1059
|
context: ({ input, self }: { input: unknown; self: { id: string } }): WsContext => ({
|
|
@@ -762,60 +1061,95 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
762
1061
|
wsId: self.id,
|
|
763
1062
|
spec: spec({ input }),
|
|
764
1063
|
}),
|
|
765
|
-
initial: "
|
|
1064
|
+
initial: "placing",
|
|
766
1065
|
states: {
|
|
767
|
-
|
|
768
|
-
invoke:
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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,
|
|
1066
|
+
placing: {
|
|
1067
|
+
invoke: [
|
|
1068
|
+
{
|
|
1069
|
+
id: "place",
|
|
1070
|
+
src: "place",
|
|
1071
|
+
input: ({ context }) => ({
|
|
1072
|
+
wsId: (context as unknown as WsContext).wsId,
|
|
1073
|
+
spec: (context as unknown as WsContext).spec,
|
|
1074
|
+
runInput: (context as unknown as WsContext).runInput,
|
|
782
1075
|
}),
|
|
1076
|
+
onDone: {
|
|
1077
|
+
target: "provisioning",
|
|
1078
|
+
actions: assign({
|
|
1079
|
+
bindings: ({ event }) =>
|
|
1080
|
+
(event as unknown as { output: { bindings: Record<string, ResolvedBinding> } }).output.bindings,
|
|
1081
|
+
wait: undefined,
|
|
1082
|
+
}),
|
|
1083
|
+
},
|
|
1084
|
+
},
|
|
1085
|
+
leaseInvoke(false),
|
|
1086
|
+
],
|
|
1087
|
+
// The wait's reason, into context and nowhere else: scheduling detail is not the body's
|
|
1088
|
+
// concern (ADR-0064), and a bound is the parent's `after`.
|
|
1089
|
+
on: {
|
|
1090
|
+
"workspace.waiting": {
|
|
1091
|
+
actions: assign({ wait: ({ event }) => (event as unknown as { wait: WorkspaceWait }).wait }),
|
|
783
1092
|
},
|
|
784
1093
|
},
|
|
785
1094
|
},
|
|
1095
|
+
provisioning: {
|
|
1096
|
+
invoke: [
|
|
1097
|
+
{
|
|
1098
|
+
id: "provision",
|
|
1099
|
+
src: "provision",
|
|
1100
|
+
input: ({ context }) => ({ wsId: (context as unknown as WsContext).wsId }),
|
|
1101
|
+
onDone: {
|
|
1102
|
+
target: "attaching",
|
|
1103
|
+
actions: assign({
|
|
1104
|
+
endpoint: ({ event }) => (event as unknown as { output: { endpoint: string } }).output.endpoint,
|
|
1105
|
+
}),
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
leaseInvoke(false),
|
|
1109
|
+
],
|
|
1110
|
+
},
|
|
786
1111
|
attaching: {
|
|
787
|
-
invoke:
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
},
|
|
1112
|
+
invoke: [
|
|
1113
|
+
{
|
|
1114
|
+
id: "attach",
|
|
1115
|
+
src: "attach",
|
|
1116
|
+
input: ({ context }) => ({
|
|
1117
|
+
wsId: (context as unknown as WsContext).wsId,
|
|
1118
|
+
spec: (context as unknown as WsContext).spec,
|
|
1119
|
+
bindings: (context as unknown as WsContext).bindings!,
|
|
816
1120
|
}),
|
|
1121
|
+
onDone: {
|
|
1122
|
+
target: "running",
|
|
1123
|
+
actions: assign({
|
|
1124
|
+
handles: ({ context, event, system }): MechanismHandles => {
|
|
1125
|
+
const ctx = context as WsContext;
|
|
1126
|
+
const out = (
|
|
1127
|
+
event as unknown as {
|
|
1128
|
+
output: { repos: Record<string, string>; review?: Record<string, string> };
|
|
1129
|
+
}
|
|
1130
|
+
).output;
|
|
1131
|
+
return {
|
|
1132
|
+
endpoint: ctx.endpoint!,
|
|
1133
|
+
// Derived, not remembered: the same function every port operation names the CR
|
|
1134
|
+
// with, so the Sandbox the registrar publishes — and the Agent actor records on its
|
|
1135
|
+
// registration — is the one the Custodian's token is scoped to, by construction
|
|
1136
|
+
// (ADR-0013).
|
|
1137
|
+
sandbox: workspaceName(runBindingOf(system as AnyActorSystem).runId, ctx.wsId),
|
|
1138
|
+
repos: out.repos,
|
|
1139
|
+
// Each slot's Binding url: where a push goes, and the Repo cache its import
|
|
1140
|
+
// borrows from (ADR-0051, ADR-0067).
|
|
1141
|
+
...(ctx.bindings
|
|
1142
|
+
? { bindings: Object.fromEntries(Object.entries(ctx.bindings).map(([s, b]) => [s, b.url])) }
|
|
1143
|
+
: {}),
|
|
1144
|
+
branch: ctx.spec.branch,
|
|
1145
|
+
...(out.review ? { review: out.review } : {}),
|
|
1146
|
+
};
|
|
1147
|
+
},
|
|
1148
|
+
}),
|
|
1149
|
+
},
|
|
817
1150
|
},
|
|
818
|
-
|
|
1151
|
+
leaseInvoke(false),
|
|
1152
|
+
],
|
|
819
1153
|
},
|
|
820
1154
|
running: {
|
|
821
1155
|
invoke: [
|
|
@@ -844,17 +1178,11 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
844
1178
|
actions: assign({ output: ({ event }) => (event as unknown as { output: unknown }).output }),
|
|
845
1179
|
},
|
|
846
1180
|
},
|
|
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
|
-
},
|
|
1181
|
+
leaseInvoke(true),
|
|
855
1182
|
],
|
|
856
|
-
// The wrapper emits, the body decides (ADR-0012): forward loss into the
|
|
857
|
-
|
|
1183
|
+
// The wrapper emits, the body decides (ADR-0012): forward loss, with its reason, into the
|
|
1184
|
+
// body's policy.
|
|
1185
|
+
on: { "workspace.lost": { actions: sendTo("body", ({ event }) => event) } },
|
|
858
1186
|
},
|
|
859
1187
|
teardown: {
|
|
860
1188
|
invoke: {
|
|
@@ -872,3 +1200,10 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
872
1200
|
output: ({ context }) => (context as unknown as WsContext).output,
|
|
873
1201
|
});
|
|
874
1202
|
}
|
|
1203
|
+
|
|
1204
|
+
/** A Sandbox the watch says is gone — reaped by the idle GC, deleted by hand, its namespace
|
|
1205
|
+
* cleared — as the loss `workspace.lost` carries. */
|
|
1206
|
+
const GONE: SandboxLoss = {
|
|
1207
|
+
reason: "Deleted",
|
|
1208
|
+
message: "the Sandbox is gone: deleted, or reaped by the operator's idle GC (ADR-0001)",
|
|
1209
|
+
};
|