@jr2/orchestrator 0.3.0 → 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.
Files changed (73) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -4
  13. package/src/actor.ts +502 -49
  14. package/src/ambient.ts +2 -0
  15. package/src/assembly-checks.ts +165 -0
  16. package/src/assembly-tar.ts +494 -0
  17. package/src/assembly.ts +721 -0
  18. package/src/chart.ts +2050 -0
  19. package/src/charts.ts +720 -0
  20. package/src/config.ts +152 -46
  21. package/src/content-hash.ts +105 -0
  22. package/src/custodian.lua +6 -5
  23. package/src/custodian.ts +71 -19
  24. package/src/customize.ts +216 -25
  25. package/src/effect-checks.ts +557 -0
  26. package/src/effect.ts +905 -0
  27. package/src/effector-wire.ts +525 -0
  28. package/src/fingerprint.ts +4 -3
  29. package/src/gate.ts +8 -0
  30. package/src/git-clone.ts +65 -0
  31. package/src/git-push.ts +336 -0
  32. package/src/harness-client.ts +245 -23
  33. package/src/held-secrets.ts +82 -26
  34. package/src/holds.ts +156 -0
  35. package/src/http-effect.ts +981 -0
  36. package/src/http.ts +496 -104
  37. package/src/images.ts +47 -21
  38. package/src/index.ts +43 -2
  39. package/src/instance.ts +444 -58
  40. package/src/kube-client.ts +333 -0
  41. package/src/machine-doc.ts +88 -6
  42. package/src/mount-digest.ts +53 -0
  43. package/src/names.ts +66 -8
  44. package/src/notices.ts +88 -0
  45. package/src/open-body.ts +1444 -0
  46. package/src/open-effect.ts +144 -0
  47. package/src/open.ts +4 -4
  48. package/src/parts.ts +530 -33
  49. package/src/party.ts +468 -0
  50. package/src/pick.ts +309 -0
  51. package/src/pool.ts +1 -1
  52. package/src/registration.ts +301 -17
  53. package/src/repo-fetch.ts +60 -85
  54. package/src/repos.ts +63 -74
  55. package/src/rest.ts +133 -0
  56. package/src/run-host.ts +1832 -98
  57. package/src/sandbox-effect.ts +891 -0
  58. package/src/sandbox-effector.ts +111 -0
  59. package/src/sandbox-kube.ts +1719 -0
  60. package/src/sandbox-watch.ts +260 -0
  61. package/src/secret-placement.ts +494 -0
  62. package/src/server.ts +281 -84
  63. package/src/setup.ts +589 -70
  64. package/src/size.ts +342 -0
  65. package/src/snapshot-store.ts +64 -13
  66. package/src/stub-harness.ts +5 -0
  67. package/src/sync-client.ts +54 -0
  68. package/src/timers.ts +178 -0
  69. package/src/tokens.ts +35 -4
  70. package/src/vocabulary.ts +66 -1
  71. package/src/wire.ts +156 -2
  72. package/src/workspace.ts +529 -194
  73. package/src/sandbox-kubectl.ts +0 -1108
package/src/workspace.ts CHANGED
@@ -1,6 +1,6 @@
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
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 all four operations must be idempotent.
17
+ // (invoked actors re-execute from persisted input), so every operation must be idempotent.
18
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.
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; also the worst-case detection latency for a
60
- * workspace that went away (ADR-0021). */
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 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.
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 Continuity = { present: false } | { present: true; identity?: string };
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. All four operations MUST be idempotent: the invoking
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
- /** 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 }>;
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 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.
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
- renew(name: string): Promise<Continuity>;
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. Also the detection latency for a lost workspace. */
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 reconcile probe
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
- /** 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;
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'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;
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
- 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
- }
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
- const provision = fromPromise<
628
- { endpoint: string; identity?: string; bindings: Record<string, ResolvedBinding> },
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 bindings = resolveBindings(
641
- { workflow: binding.workflow, path: actorSlotPath(self._parent) },
642
- parts.repos,
643
- input.runInput,
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
- 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 };
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
- const out = await sandboxOf(system).attach({ name, spec: input.spec, repos: attachedRepos(input.bindings) });
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 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.
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. 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.
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
- * 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.
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; 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
- };
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
- 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
- });
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 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".
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: "provisioning",
1064
+ initial: "placing",
766
1065
  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,
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
- 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 Custodian'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 body's policy.
857
- on: { "workspace.lost": { actions: sendTo("body", { type: "workspace.lost" }) } },
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
+ };