@namzu/sandbox 13.0.0 → 14.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/README.md +151 -0
  3. package/dist/backends/firecracker/protocol.d.ts +22 -0
  4. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  5. package/dist/backends/firecracker/protocol.js.map +1 -1
  6. package/dist/backends/firecracker/transport.d.ts +104 -9
  7. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  8. package/dist/backends/firecracker/transport.js +139 -13
  9. package/dist/backends/firecracker/transport.js.map +1 -1
  10. package/dist/backends/kubernetes/egress-policy.d.ts +219 -0
  11. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -0
  12. package/dist/backends/kubernetes/egress-policy.js +314 -0
  13. package/dist/backends/kubernetes/egress-policy.js.map +1 -0
  14. package/dist/backends/kubernetes/index.d.ts +374 -0
  15. package/dist/backends/kubernetes/index.d.ts.map +1 -0
  16. package/dist/backends/kubernetes/index.js +671 -0
  17. package/dist/backends/kubernetes/index.js.map +1 -0
  18. package/dist/backends/kubernetes/k8s-client.d.ts +125 -0
  19. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -0
  20. package/dist/backends/kubernetes/k8s-client.js +246 -0
  21. package/dist/backends/kubernetes/k8s-client.js.map +1 -0
  22. package/dist/backends/kubernetes/lease.d.ts +119 -0
  23. package/dist/backends/kubernetes/lease.d.ts.map +1 -0
  24. package/dist/backends/kubernetes/lease.js +151 -0
  25. package/dist/backends/kubernetes/lease.js.map +1 -0
  26. package/dist/backends/kubernetes/objects.d.ts +282 -0
  27. package/dist/backends/kubernetes/objects.d.ts.map +1 -0
  28. package/dist/backends/kubernetes/objects.js +156 -0
  29. package/dist/backends/kubernetes/objects.js.map +1 -0
  30. package/dist/backends/kubernetes/privilege-probe.d.ts +136 -0
  31. package/dist/backends/kubernetes/privilege-probe.d.ts.map +1 -0
  32. package/dist/backends/kubernetes/privilege-probe.js +185 -0
  33. package/dist/backends/kubernetes/privilege-probe.js.map +1 -0
  34. package/dist/backends/kubernetes/sandbox.d.ts +123 -0
  35. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -0
  36. package/dist/backends/kubernetes/sandbox.js +299 -0
  37. package/dist/backends/kubernetes/sandbox.js.map +1 -0
  38. package/dist/backends/kubernetes/transport.d.ts +122 -0
  39. package/dist/backends/kubernetes/transport.d.ts.map +1 -0
  40. package/dist/backends/kubernetes/transport.js +197 -0
  41. package/dist/backends/kubernetes/transport.js.map +1 -0
  42. package/dist/backends/kubernetes/workspace.d.ts +381 -0
  43. package/dist/backends/kubernetes/workspace.d.ts.map +1 -0
  44. package/dist/backends/kubernetes/workspace.js +1064 -0
  45. package/dist/backends/kubernetes/workspace.js.map +1 -0
  46. package/dist/index.d.ts +132 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +102 -34
  49. package/dist/index.js.map +1 -1
  50. package/dist/testing/sandbox-conformance.d.ts +193 -0
  51. package/dist/testing/sandbox-conformance.d.ts.map +1 -0
  52. package/dist/testing/sandbox-conformance.js +465 -0
  53. package/dist/testing/sandbox-conformance.js.map +1 -0
  54. package/package.json +5 -4
  55. package/src/backends/firecracker/protocol.ts +27 -0
  56. package/src/backends/firecracker/transport.ts +199 -28
  57. package/src/backends/kubernetes/egress-policy.ts +437 -0
  58. package/src/backends/kubernetes/index.ts +1012 -0
  59. package/src/backends/kubernetes/k8s-client.ts +352 -0
  60. package/src/backends/kubernetes/lease.ts +198 -0
  61. package/src/backends/kubernetes/objects.ts +363 -0
  62. package/src/backends/kubernetes/privilege-probe.ts +261 -0
  63. package/src/backends/kubernetes/sandbox.ts +395 -0
  64. package/src/backends/kubernetes/transport.ts +286 -0
  65. package/src/backends/kubernetes/workspace.ts +1386 -0
  66. package/src/index.ts +257 -35
  67. package/src/testing/sandbox-conformance.ts +667 -0
@@ -0,0 +1,1386 @@
1
+ /**
2
+ * The persistent workspace: a Sandbox that keeps its disk across suspends.
3
+ *
4
+ * A task sandbox (`index.ts`) is claimed, used and deleted inside one run. A
5
+ * workspace is the opposite object: it is created once, addressed by a name
6
+ * the CALLER chooses, suspended when nobody is using it, resumed days later
7
+ * with yesterday's dependency cache and git checkout still on its disk, and
8
+ * deleted only when someone says so.
9
+ *
10
+ * ## Why this is a directly created Sandbox and never a claim
11
+ *
12
+ * `Sandbox.spec.volumeClaimTemplates` is CEL-immutable on the served CRD
13
+ * ("volumeClaimTemplates is immutable"), and a `SandboxClaim` that carries
14
+ * `spec.volumeClaimTemplates` is forced to cold-start instead of adopting a
15
+ * warm pool sandbox. So the disk has to be in the spec at creation, and the
16
+ * appealing middle road — claim a warm diskless sandbox and attach a disk to
17
+ * it — is not expressible in this API at all. A workspace is therefore a
18
+ * `Sandbox` POSTed directly, with a deterministic name, and the warm pool has
19
+ * nothing to do with it.
20
+ *
21
+ * ## Block, not a filesystem
22
+ *
23
+ * The disk must be `volumeMode: Block`, consumed through the container's
24
+ * `volumeDevices`, and this module REFUSES a template whose disk is anything
25
+ * else. Under a VM-isolating RuntimeClass a `Filesystem` PVC reaches the guest
26
+ * through a host/guest filesystem passthrough (virtio-fs), whose per-file
27
+ * overhead lands squarely on the two things a workspace does all day: walking
28
+ * a dependency tree and touching thousands of small files. Nothing FAILS; the
29
+ * workspace is merely several times slower, and no functional test can see
30
+ * that. A raw block device the guest formats and mounts itself is an ordinary
31
+ * local filesystem inside the VM. See {@link KubernetesWorkspaceDiskError}.
32
+ *
33
+ * ## No lease
34
+ *
35
+ * Every task sandbox carries `shutdownTime` + `shutdownPolicy: Delete`, so a
36
+ * host that dies mid-run costs the cluster one expiry rather than a leak, and
37
+ * its handle renews that expiry for as long as it lives. A workspace carries
38
+ * NEITHER. An expiry on a workspace is a timer that deletes a caller's files,
39
+ * and a renewal loop makes losing them conditional on a host process staying
40
+ * up — exactly backwards for an object whose whole purpose is to outlive the
41
+ * host. A workspace is explicitly managed: it goes away when
42
+ * `destroy({ deleteDisk: true })` says so, and not before.
43
+ *
44
+ * ## Suspend, resume, and what changes across one
45
+ *
46
+ * `suspend()` merge-PATCHes `spec.operatingMode: Suspended`; the controller
47
+ * deletes only the Pod and reconciles PVCs unconditionally on every pass, so
48
+ * the disk survives with the same UID. It then waits on the POD — not on the
49
+ * Sandbox's `Suspended` condition, which upstream documents as lingering True
50
+ * after a resume, and not merely on a `deletionTimestamp`, which appears
51
+ * while the guest is still running. `resume()` PATCHes it back and waits
52
+ * for a new pod — and a resumed pod keeps the sandbox's NAME while getting a
53
+ * new uid and a new IP. Both matter: the uid is the agent's bind token, and
54
+ * the IP is where the agent answers. So resume re-resolves the address, re-
55
+ * reads the uid (skipping the outgoing pod, which is still listed under the
56
+ * same name while it terminates) and rebuilds the transport. Nothing from
57
+ * before the suspend is reused.
58
+ *
59
+ * Between the two, every call refuses with
60
+ * {@link KubernetesWorkspaceSuspendedError} and issues no dial. A dial would
61
+ * be worse than useless: the address still resolves — the Service outlives
62
+ * the pod — so the call would hang until a connect timeout with nothing in
63
+ * the failure naming the suspend.
64
+ *
65
+ * ## Adoption is checked against the object, not against the caller
66
+ *
67
+ * A create that collides with an existing object of the same name ADOPTS it,
68
+ * because the deterministic name is only worth having if coming back is the
69
+ * normal path. What is adopted is then checked against the configuration: the
70
+ * block disk, the `sandbox.namzu.ai/template` pod label (the label an egress
71
+ * NetworkPolicy selects by) and `runtimeClassName` (the VM boundary). A
72
+ * standing object that disagrees with any of them is refused by name rather
73
+ * than driven — see {@link KubernetesWorkspaceMismatchError}. What is NOT
74
+ * checked, and cannot be from here, is whether somebody else is already using
75
+ * it: two host processes can hold handles to one running workspace, and the
76
+ * `destroy()` of either suspends the pod the other is executing in. A
77
+ * workspace id is a name, not a lock.
78
+ *
79
+ * ## There is no delete-compute-keep-disk verb
80
+ *
81
+ * The API has `operatingMode` and it has DELETE. Nothing in between. So
82
+ * `destroy()` with no options, or `deleteDisk: false`, SUSPENDS and leaves
83
+ * the object standing; only `destroy({ deleteDisk: true })` DELETEs the
84
+ * Sandbox, which cascades to the Pod, the Service and the PVC through
85
+ * ownerReferences. The default is the non-destructive one because `destroy()`
86
+ * is what a `finally` block calls, and a `finally` block must not be able to
87
+ * erase a workspace nobody asked to erase.
88
+ *
89
+ * The same holds for the paths nobody asked for at all. A create or resume
90
+ * that fails suspends the object and rethrows rather than cleaning it up (see
91
+ * {@link createKubernetesWorkspace}), and a pod that stops being able to say
92
+ * what happened to a command — the shared execution controller's unconfirmed
93
+ * cancellation, which retires a task sandbox by DELETING it — is retired here
94
+ * by that same suspend patch (see {@link retireSession}). Exactly one DELETE
95
+ * is reachable from this module, and it is the one `deleteDisk: true` asks
96
+ * for.
97
+ *
98
+ * ## A state is committed when the cluster confirms it, never before
99
+ *
100
+ * Both verbs are idempotent, and both are idempotent by EARLY-RETURNING on a
101
+ * state. That makes the moment a state is written the whole correctness
102
+ * question: a handle that marks itself `deleted` before its DELETE lands
103
+ * answers every later `destroy()` from that mark, so a 500 is thrown once and
104
+ * the Sandbox then stands on the cluster with nothing left that would remove
105
+ * it. The same shape on `suspend()` leaves a pod running — and billing —
106
+ * behind a handle that says it is suspended.
107
+ *
108
+ * So `suspended` is written after the patch lands AND the pod is observed
109
+ * stopped, `deleted` after the DELETE resolves (or reports the object already
110
+ * gone), and a request that fails leaves the state it found. Concurrency is
111
+ * covered the other way round, by a single flight per verb: a second caller
112
+ * arriving mid-transition awaits the one in progress instead of sending a
113
+ * second request into the gap the deferred mark opens.
114
+ */
115
+
116
+ import type {
117
+ OpenTerminalOptions,
118
+ Sandbox,
119
+ SandboxDestroyOptions,
120
+ SandboxEnvironment,
121
+ SandboxExecOptions,
122
+ SandboxExecResult,
123
+ SandboxFileEntry,
124
+ SandboxId,
125
+ SandboxStatus,
126
+ SandboxTcpConnectOptions,
127
+ SandboxTcpConnection,
128
+ TerminalSession,
129
+ } from '@namzu/sdk'
130
+
131
+ import { OperationDeadline, OperationDeadlineExpired, runFailureCleanup } from '../readiness.js'
132
+ import { assertEgressPolicyIsEnforceable } from './egress-policy.js'
133
+ import {
134
+ DEFAULT_AGENT_PORT,
135
+ type KubernetesBackendInternalConfig,
136
+ type KubernetesSandboxBinding,
137
+ bindingFromSandbox,
138
+ buildSandboxBody,
139
+ clientAccess,
140
+ pollForBinding,
141
+ probeSandboxPrivileges,
142
+ readPodBindToken,
143
+ readSandboxTemplate,
144
+ resolveAgentAddress,
145
+ resolveKubernetesReadiness,
146
+ resolveProbeTimeoutMs,
147
+ verifyEgressPolicyConfigured,
148
+ } from './index.js'
149
+ import {
150
+ KubernetesAlreadyGoneError,
151
+ type KubernetesClient,
152
+ KubernetesConflictError,
153
+ createKubernetesClient,
154
+ } from './k8s-client.js'
155
+ import {
156
+ type PodResource,
157
+ SANDBOX_TEMPLATE_LABEL_KEY,
158
+ type SandboxPodTemplate,
159
+ type SandboxResource,
160
+ type SandboxVolumeClaimTemplate,
161
+ isPodStopped,
162
+ podPath,
163
+ sandboxCollectionPath,
164
+ sandboxPath,
165
+ } from './objects.js'
166
+ import {
167
+ KubernetesSandboxDestroyedError,
168
+ type KubernetesSandboxHandle,
169
+ buildKubernetesSandbox,
170
+ } from './sandbox.js'
171
+ import { KubernetesAgentTransport } from './transport.js'
172
+
173
+ /**
174
+ * Thrown when a workspace's `SandboxTemplate` does not describe a block disk
175
+ * this backend is willing to build a workspace on.
176
+ *
177
+ * Named, and thrown before anything is created, because every shape it
178
+ * refuses WORKS: a template with no disk produces a sandbox whose files
179
+ * vanish on the next suspend, and a `Filesystem` disk produces one that keeps
180
+ * its files and is quietly several times slower at the small-file IO a
181
+ * workspace is made of. Neither fails a functional test, so neither can be
182
+ * left to be noticed later.
183
+ */
184
+ export class KubernetesWorkspaceDiskError extends Error {
185
+ override readonly name = 'KubernetesWorkspaceDiskError'
186
+
187
+ constructor(
188
+ /** What was inspected — a `SandboxTemplate`, or an existing Sandbox. */
189
+ readonly source: string,
190
+ message: string,
191
+ ) {
192
+ super(message)
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Thrown when the Sandbox already standing under a workspace's name was not
198
+ * built the way this caller is configured to build one.
199
+ *
200
+ * Adoption is the NORMAL path — the deterministic name exists so that coming
201
+ * back to a workspace is cheap — and that is exactly why the object handed
202
+ * back is checked against the configuration rather than against the caller's
203
+ * intention. Two controls would otherwise be lost silently, and lost for as
204
+ * long as the workspace lives, which is the longest of anything this backend
205
+ * makes:
206
+ *
207
+ * - the `sandbox.namzu.ai/template` pod label, which is what a translated
208
+ * egress `NetworkPolicy`'s `podSelector` matches. A pod carrying another
209
+ * value — or none — is not selected by the policy this call just verified,
210
+ * so the boundary would report verified while covering nothing.
211
+ * - `runtimeClassName`, which is the VM boundary. The privilege probe cannot
212
+ * stand in for it: `/proc/self/status` reads the same inside a VM guest as
213
+ * it does inside an ordinary shared-kernel container.
214
+ *
215
+ * Nothing is patched to make the standing object match. Its disk may hold a
216
+ * month of the caller's files, and rewriting a live workspace's podTemplate to
217
+ * fit a new configuration is a larger decision than reattaching to it.
218
+ */
219
+ export class KubernetesWorkspaceMismatchError extends Error {
220
+ override readonly name = 'KubernetesWorkspaceMismatchError'
221
+
222
+ constructor(
223
+ /** The Sandbox found standing under this workspace's name. */
224
+ readonly sandboxName: string,
225
+ /** Which configured field the standing object disagrees with. */
226
+ readonly field: 'sandboxTemplateName' | 'runtimeClassName',
227
+ /** What the configuration asked for. */
228
+ readonly expected: string,
229
+ /** What the object carries — absent when it carries nothing at all. */
230
+ readonly actual: string | undefined,
231
+ message: string,
232
+ ) {
233
+ super(message)
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Thrown by every operation on a workspace that is currently suspended.
239
+ *
240
+ * Distinct from {@link KubernetesSandboxDestroyedError} because the state is
241
+ * RECOVERABLE and the advice is one word: call `resume()`. Nothing is dialed
242
+ * before it is thrown.
243
+ */
244
+ export class KubernetesWorkspaceSuspendedError extends Error {
245
+ override readonly name = 'KubernetesWorkspaceSuspendedError'
246
+
247
+ constructor(
248
+ readonly operation: string,
249
+ readonly workspaceId: string,
250
+ readonly sandboxName: string,
251
+ ) {
252
+ super(
253
+ `kubernetes workspace ${workspaceId} (Sandbox ${sandboxName}) is suspended; ${operation}() cannot be admitted and nothing was dialed. Its pod is deleted and its disk is intact — call resume() to get a new pod, a new address and a new agent token, then retry.`,
254
+ )
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Thrown when a suspend's `operatingMode: Suspended` patch was accepted and
260
+ * the pod had still not stopped by the readiness deadline.
261
+ *
262
+ * The workspace is left in the state that is TRUE rather than the one that
263
+ * was asked for: the patch landed, so the pod is on its way out and no call
264
+ * is admitted — but the suspend is not recorded as finished, because it did
265
+ * not finish. A later `suspend()` sends the patch again and waits again
266
+ * instead of returning on a mark this one left behind, and `resume()` still
267
+ * works.
268
+ *
269
+ * Recording it as suspended here is exactly the defect this class exists to
270
+ * make impossible. The guest is still running, still holding the block device
271
+ * open and still writing to it, and "suspended" is a promise that the disk is
272
+ * quiesced — so a handle that made that promise on a wait it lost would let
273
+ * the next caller resume, or delete, a workspace mid-write.
274
+ */
275
+ export class KubernetesWorkspaceSuspendTimeoutError extends Error {
276
+ override readonly name = 'KubernetesWorkspaceSuspendTimeoutError'
277
+
278
+ constructor(
279
+ readonly workspaceId: string,
280
+ readonly sandboxName: string,
281
+ /** The readiness budget the wait was given, in milliseconds. */
282
+ readonly timeoutMs: number,
283
+ ) {
284
+ super(
285
+ `kubernetes: workspace ${workspaceId} (Sandbox ${sandboxName}) was patched to operatingMode: Suspended but its pod had still not stopped ${timeoutMs}ms later, so the suspend is UNCONFIRMED and the disk cannot be promised quiesced. A guest whose PID 1 ignores SIGTERM rides out its terminationGracePeriodSeconds first; raise readyTimeoutMs, or make the image exit promptly on SIGTERM. Nothing was deleted and the disk is untouched: no call is admitted while the pod drains, suspend() sends the patch again and waits again, and resume() brings the workspace back.`,
286
+ )
287
+ }
288
+ }
289
+
290
+ /** Authority for one lifecycle transition, owned independently of the run. */
291
+ export interface KubernetesWorkspaceTransitionOptions {
292
+ readonly signal?: AbortSignal
293
+ }
294
+
295
+ /**
296
+ * `destroy()` on a workspace, with the one field that decides whether the
297
+ * disk survives. See the module comment: the default keeps it.
298
+ */
299
+ export interface KubernetesWorkspaceDestroyOptions extends SandboxDestroyOptions {
300
+ /**
301
+ * `true` DELETEs the Sandbox, cascading to its Pod, Service and PVC —
302
+ * the caller's files are gone and nothing brings them back. Anything else,
303
+ * including the default, suspends and leaves the object standing.
304
+ */
305
+ readonly deleteDisk?: boolean
306
+ }
307
+
308
+ /**
309
+ * A {@link Sandbox} that survives having its compute taken away.
310
+ *
311
+ * Declared here rather than on the SDK's `Sandbox`: the brief frames these as
312
+ * BACKEND capabilities, and keeping them out of `@namzu/sdk` means no new
313
+ * `SandboxStatus` member, no optional `suspend?()`/`resume?()` on the shared
314
+ * contract that every other backend would then have to answer for, and no
315
+ * `deleteDisk` field on the shared `SandboxDestroyOptions`.
316
+ */
317
+ export interface KubernetesWorkspace extends Sandbox {
318
+ /**
319
+ * True from the moment `suspend()` starts until `resume()` finishes.
320
+ *
321
+ * `status` cannot say this — `SandboxStatus` has four members and none of
322
+ * them is "suspended" — so a suspended workspace reports `destroyed`,
323
+ * which is the only member that means "cannot serve a call". This flag is
324
+ * what tells the recoverable state from the final one without widening the
325
+ * SDK's union.
326
+ */
327
+ readonly suspended: boolean
328
+ openTerminal(options: OpenTerminalOptions): Promise<TerminalSession>
329
+ openTcpConnection(options: SandboxTcpConnectOptions): Promise<SandboxTcpConnection>
330
+ /**
331
+ * Give the compute back and keep the disk. Idempotent: suspending a
332
+ * workspace whose suspend has been CONFIRMED sends nothing.
333
+ *
334
+ * Resolves once the POD has actually stopped, not merely once the patch
335
+ * was accepted and not on the Sandbox's `Suspended` condition, which
336
+ * upstream leaves standing after a resume — a guest that ignores SIGTERM
337
+ * rides out its `terminationGracePeriodSeconds` first, and it is writing
338
+ * to the disk for all of it.
339
+ *
340
+ * Rejects, leaving the workspace usable and unchanged, if the cluster
341
+ * refuses the patch; rejects with {@link KubernetesWorkspaceSuspendTimeoutError},
342
+ * admitting no call, if the patch landed and the pod outlived the wait.
343
+ * Either way the next `suspend()` sends the patch again rather than
344
+ * returning on this one's word. Concurrent calls share one transition.
345
+ *
346
+ * A call already in flight when this is called is not cancelled: it fails
347
+ * at the transport when the pod goes away, rather than with the named
348
+ * suspended error, which only covers calls admitted from here on.
349
+ */
350
+ suspend(options?: KubernetesWorkspaceTransitionOptions): Promise<void>
351
+ /**
352
+ * Take a new pod, on a new address, with a new agent token, and prove it
353
+ * is deprivileged before handing it back. Idempotent: resuming a running
354
+ * workspace sends nothing.
355
+ */
356
+ resume(options?: KubernetesWorkspaceTransitionOptions): Promise<void>
357
+ /**
358
+ * With no options, or `deleteDisk: false`, this SUSPENDS: the disk stays
359
+ * and so does the handle, which reports `status: 'destroyed'` and
360
+ * `suspended: true` and can still be `resume()`d. Only
361
+ * `deleteDisk: true` is final.
362
+ *
363
+ * A `deleteDisk: true` whose DELETE fails REJECTS and stays retryable —
364
+ * the workspace is not recorded as deleted on a request that did not
365
+ * land, because the early return on that record is what a retry would
366
+ * hit. Concurrent calls share one DELETE.
367
+ */
368
+ destroy(options?: KubernetesWorkspaceDestroyOptions): Promise<void>
369
+ }
370
+
371
+ export interface KubernetesWorkspaceOptions {
372
+ /**
373
+ * The caller's own stable name for this workspace. The Sandbox is named
374
+ * `namzu-ws-<workspaceId>`, deterministically, which is what makes a
375
+ * workspace reachable again from a different host process — see
376
+ * {@link workspaceSandboxName} for why the id is refused rather than
377
+ * sanitised.
378
+ */
379
+ readonly workspaceId: string
380
+ /** Reported as `rootDir`; where the guest's entrypoint mounted the disk. */
381
+ readonly workingDirectory: string
382
+ /**
383
+ * `SandboxTemplate` whose `podTemplate` AND `volumeClaimTemplates` this
384
+ * workspace is built from. Defaults to the backend's own
385
+ * `sandboxTemplateName`, but a deployment normally has two — a task
386
+ * template with no disk, and a workspace template with a block one.
387
+ */
388
+ readonly sandboxTemplateName?: string
389
+ readonly signal?: AbortSignal
390
+ }
391
+
392
+ /** Prefix every workspace Sandbox's name carries. */
393
+ export const WORKSPACE_NAME_PREFIX = 'namzu-ws-'
394
+
395
+ /** DNS-1123 label: the Sandbox's name is also its Pod's and its Service's. */
396
+ const DNS_LABEL_MAX_LENGTH = 63
397
+ const MAX_WORKSPACE_ID_LENGTH = DNS_LABEL_MAX_LENGTH - WORKSPACE_NAME_PREFIX.length
398
+ const WORKSPACE_ID_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
399
+
400
+ /**
401
+ * The Sandbox name for a workspace id.
402
+ *
403
+ * Deterministic on purpose: it is the only way a second host process, or the
404
+ * same one tomorrow, finds the workspace again. Which is also why an id that
405
+ * does not already fit a DNS-1123 label is REFUSED rather than lowercased,
406
+ * stripped or hashed: sanitising maps two ids onto one name, and two callers
407
+ * who believe they have separate workspaces would be sharing one disk.
408
+ * Hashing would fit every id and make the name unreadable in `kubectl get
409
+ * sandbox`, which is most of what the deterministic name is for.
410
+ */
411
+ export function workspaceSandboxName(workspaceId: string): string {
412
+ if (!WORKSPACE_ID_PATTERN.test(workspaceId) || workspaceId.length > MAX_WORKSPACE_ID_LENGTH) {
413
+ throw new Error(
414
+ `kubernetes: workspace id ${JSON.stringify(workspaceId)} cannot name a Sandbox. It must be 1-${MAX_WORKSPACE_ID_LENGTH} characters of lowercase letters, digits and '-', starting and ending alphanumeric, so that ${WORKSPACE_NAME_PREFIX}<id> is a legal DNS-1123 label for the Sandbox, its Pod and its Service. The id is refused rather than sanitised because two ids that sanitise to one name would silently share one disk.`,
415
+ )
416
+ }
417
+ return `${WORKSPACE_NAME_PREFIX}${workspaceId}`
418
+ }
419
+
420
+ function readArray(value: unknown): readonly unknown[] {
421
+ return Array.isArray(value) ? value : []
422
+ }
423
+
424
+ function readNames(container: unknown, field: 'volumeDevices' | 'volumeMounts'): string[] {
425
+ if (typeof container !== 'object' || container === null) return []
426
+ const entries = readArray((container as Record<string, unknown>)[field])
427
+ const names: string[] = []
428
+ for (const entry of entries) {
429
+ if (typeof entry !== 'object' || entry === null) continue
430
+ const name = (entry as { name?: unknown }).name
431
+ if (typeof name === 'string' && name !== '') names.push(name)
432
+ }
433
+ return names
434
+ }
435
+
436
+ /**
437
+ * Every name a pod template's containers claim through `volumeDevices` (a raw
438
+ * block device) or `volumeMounts` (a filesystem), across both container lists.
439
+ * The pod spec is carried opaquely everywhere else in this backend, so it is
440
+ * read defensively here rather than typed: an unexpected shape contributes
441
+ * nothing and lets the named refusal below do the talking.
442
+ */
443
+ function readVolumeConsumers(podTemplate: SandboxPodTemplate | undefined): {
444
+ devices: Set<string>
445
+ mounts: Set<string>
446
+ } {
447
+ const devices = new Set<string>()
448
+ const mounts = new Set<string>()
449
+ const spec = podTemplate?.spec
450
+ for (const key of ['containers', 'initContainers'] as const) {
451
+ for (const container of readArray(spec?.[key])) {
452
+ for (const name of readNames(container, 'volumeDevices')) devices.add(name)
453
+ for (const name of readNames(container, 'volumeMounts')) mounts.add(name)
454
+ }
455
+ }
456
+ return { devices, mounts }
457
+ }
458
+
459
+ /**
460
+ * Refuse anything that is not a block disk a container actually consumes as
461
+ * one. Every branch here describes a configuration that would work and then
462
+ * disappoint — see {@link KubernetesWorkspaceDiskError}.
463
+ */
464
+ export function assertBlockModeWorkspaceDisk(
465
+ source: string,
466
+ podTemplate: SandboxPodTemplate | undefined,
467
+ volumeClaimTemplates: readonly SandboxVolumeClaimTemplate[] | undefined,
468
+ ): void {
469
+ if (volumeClaimTemplates === undefined || volumeClaimTemplates.length === 0) {
470
+ throw new KubernetesWorkspaceDiskError(
471
+ source,
472
+ `kubernetes: ${source} declares no spec.volumeClaimTemplates, so a workspace built from it would have no disk and would lose everything on its first suspend — that is a task sandbox, not a workspace. Add a volumeClaimTemplates entry with spec.volumeMode: Block and consume it from the container's volumeDevices.`,
473
+ )
474
+ }
475
+ const { devices, mounts } = readVolumeConsumers(podTemplate)
476
+ for (const entry of volumeClaimTemplates) {
477
+ const name = entry.metadata?.name
478
+ if (typeof name !== 'string' || name === '') {
479
+ throw new KubernetesWorkspaceDiskError(
480
+ source,
481
+ `kubernetes: ${source} declares a spec.volumeClaimTemplates entry with no metadata.name. The controller wires the disk by that name, StatefulSet style — it creates the PVC as <entry name>-<sandbox name> and matches the container's volumeDevices entry against it — so an unnamed entry reaches no container at all.`,
482
+ )
483
+ }
484
+ const volumeMode = entry.spec?.volumeMode
485
+ if (volumeMode !== 'Block') {
486
+ throw new KubernetesWorkspaceDiskError(
487
+ source,
488
+ `kubernetes: ${source} declares volumeClaimTemplate ${JSON.stringify(name)} with volumeMode ${JSON.stringify(volumeMode ?? 'Filesystem (the API default)')}, but a persistent workspace's disk must be volumeMode: Block. Under a VM-isolating RuntimeClass a Filesystem PVC reaches the guest over a host/guest filesystem passthrough (virtio-fs), which pays a round trip per file operation — a dependency tree walk or a git status over a large checkout is several times slower, while nothing fails and no functional test can see it. A Block volume is a raw device the guest formats once and mounts as an ordinary local filesystem. Set spec.volumeMode: Block on this entry and consume it through the container's volumeDevices.`,
489
+ )
490
+ }
491
+ if (mounts.has(name)) {
492
+ throw new KubernetesWorkspaceDiskError(
493
+ source,
494
+ `kubernetes: ${source} consumes the Block volumeClaimTemplate ${JSON.stringify(name)} through a container's volumeMounts. A raw block device is claimed through volumeDevices (which gives the container a device node at devicePath); volumeMounts is the filesystem form and the kubelet will refuse the pod. Move the entry to volumeDevices and let the image's entrypoint format and mount the device.`,
495
+ )
496
+ }
497
+ if (!devices.has(name)) {
498
+ throw new KubernetesWorkspaceDiskError(
499
+ source,
500
+ `kubernetes: ${source} declares the Block volumeClaimTemplate ${JSON.stringify(name)} but no container claims it through volumeDevices, so the PVC is provisioned and attached to nothing. Add a volumeDevices entry naming ${JSON.stringify(name)} with the devicePath the image's entrypoint formats and mounts.`,
501
+ )
502
+ }
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Refuse a standing Sandbox that was built from another `SandboxTemplate`, or
508
+ * that runs without the RuntimeClass this backend is configured for.
509
+ *
510
+ * Read off `spec.podTemplate` — the copy the controller actually runs a pod
511
+ * from — rather than off anything this process decided, because the question
512
+ * is what the POD is, not what the caller meant it to be.
513
+ *
514
+ * The template check is unconditional, `config.egress` set or not: the label
515
+ * is also how an operator reads which template an object came from, and an
516
+ * object whose label says one thing while the caller builds from another is a
517
+ * mix-up worth naming the first time it is seen rather than the first time a
518
+ * policy is switched on. See {@link KubernetesWorkspaceMismatchError}.
519
+ */
520
+ export function assertAdoptedWorkspaceMatchesConfig(
521
+ sandboxName: string,
522
+ namespace: string,
523
+ podTemplate: SandboxPodTemplate | undefined,
524
+ expected: { readonly sandboxTemplateName: string; readonly runtimeClassName?: string },
525
+ ): void {
526
+ const label = podTemplate?.metadata?.labels?.[SANDBOX_TEMPLATE_LABEL_KEY]
527
+ if (label !== expected.sandboxTemplateName) {
528
+ throw new KubernetesWorkspaceMismatchError(
529
+ sandboxName,
530
+ 'sandboxTemplateName',
531
+ expected.sandboxTemplateName,
532
+ label,
533
+ `kubernetes: Sandbox ${sandboxName} in namespace ${namespace} already exists, and its spec.podTemplate carries ${SANDBOX_TEMPLATE_LABEL_KEY}: ${label === undefined ? '(absent)' : JSON.stringify(label)} rather than ${JSON.stringify(expected.sandboxTemplateName)} — it was built from a different SandboxTemplate, so it is not the workspace this call describes. That label is the one an egress NetworkPolicy's podSelector matches, so adopting this object would hand back a pod the policy verified for ${JSON.stringify(expected.sandboxTemplateName)} does not select, having reported the boundary as verified. Point this workspace at the template the object was built from, or delete the Sandbox — which takes its disk with it — and create it again, or choose another workspaceId.`,
534
+ )
535
+ }
536
+ if (expected.runtimeClassName === undefined) return
537
+ const declared = podTemplate?.spec?.runtimeClassName
538
+ const actual = typeof declared === 'string' ? declared : undefined
539
+ if (actual !== expected.runtimeClassName) {
540
+ throw new KubernetesWorkspaceMismatchError(
541
+ sandboxName,
542
+ 'runtimeClassName',
543
+ expected.runtimeClassName,
544
+ actual,
545
+ `kubernetes: Sandbox ${sandboxName} in namespace ${namespace} already exists, and its spec.podTemplate.spec.runtimeClassName is ${actual === undefined ? "(absent — the cluster's default runtime)" : JSON.stringify(actual)} rather than the configured ${JSON.stringify(expected.runtimeClassName)}. Running it would put the workspace on that runtime — a shared kernel, if it is the default — while this backend registers itself as tier 'microvm', and nothing downstream would notice: the privilege probe reads /proc/self/status inside the guest and passes identically under a VM and under runc. A standing object's RuntimeClass is not something this backend rewrites underneath a disk it did not create, so the mismatch is named instead. Delete the Sandbox — which takes its disk with it — and create it again under ${JSON.stringify(expected.runtimeClassName)}, or drop runtimeClassName from the config if this object's runtime is the intended one.`,
546
+ )
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Where the workspace is, as far as the CLUSTER has CONFIRMED it.
552
+ *
553
+ * - `running` — a pod is up and `session` serves calls.
554
+ * - `suspending` — the suspend patch landed, so the pod is going away and no
555
+ * call is admitted, but the pod has not been observed stopped yet. Also
556
+ * where a suspend that ran out of that wait leaves the workspace.
557
+ * - `suspended` — the patch landed AND the pod was observed stopped. Only
558
+ * this one is a state a second `suspend()` may return from without
559
+ * touching the cluster.
560
+ * - `deleted` — the DELETE landed. Terminal.
561
+ *
562
+ * Every state that an operation early-returns on is committed AFTER the
563
+ * request that causes it resolves, never before. A state marked on the way
564
+ * out turns a FAILED request into a silent success, because that early return
565
+ * is what every later call reads: a destroy whose DELETE 500s would answer
566
+ * "already deleted" forever while the object stood on the cluster, and a
567
+ * suspend whose PATCH 500s would answer "already suspended" while the pod
568
+ * kept running.
569
+ */
570
+ type WorkspaceState = 'running' | 'suspending' | 'suspended' | 'deleted'
571
+
572
+ /**
573
+ * Create the workspace, or take over the one that is already there.
574
+ *
575
+ * A second call with the same `workspaceId` ADOPTS rather than fails: the
576
+ * POST comes back 409 Conflict, and the object it collided with is this
577
+ * caller's own workspace from an earlier process. The deterministic name is
578
+ * only useful if coming back to it is the normal path. An adopted object is
579
+ * checked against the same block-disk rule a fresh one is, so a Sandbox
580
+ * standing under this name that is not a workspace is refused rather than
581
+ * used.
582
+ *
583
+ * Nothing here is ever deleted on failure. A create that gets as far as an
584
+ * existing object and then fails — a readiness timeout, a privilege probe
585
+ * refusal — SUSPENDS it and rethrows, because the object may be a workspace
586
+ * with a disk full of the caller's files and `deleteDisk` is not a decision
587
+ * a failure path gets to make. That holds even when THIS call POSTed the
588
+ * object and its disk is therefore empty: the 409 above means two processes
589
+ * can be coming up on one name at once, and the one that got the 201 deleting
590
+ * its "own" fresh object would take the disk of the one that adopted it. The
591
+ * cost is named rather than paid: a failed create can leave one suspended
592
+ * Sandbox standing, which the caller finds again under the same deterministic
593
+ * name and nothing reaps for them — see the docs page.
594
+ */
595
+ export async function createKubernetesWorkspace(
596
+ config: KubernetesBackendInternalConfig,
597
+ options: KubernetesWorkspaceOptions,
598
+ ): Promise<KubernetesWorkspace> {
599
+ options.signal?.throwIfAborted()
600
+ const readiness = resolveKubernetesReadiness(config)
601
+ const namespace = config.namespace
602
+ const name = workspaceSandboxName(options.workspaceId)
603
+ const templateName = options.sandboxTemplateName ?? config.sandboxTemplateName
604
+ const agentPort = config.agentPort ?? DEFAULT_AGENT_PORT
605
+ const client = createKubernetesClient(clientAccess(config))
606
+
607
+ // The same two egress steps `buildKubernetesBackend` runs for a task
608
+ // sandbox, repeated here because a workspace never goes through it. The
609
+ // refusal is synchronous and decided from the policy KIND alone; the
610
+ // verification is a GET of the object an operator was supposed to apply,
611
+ // and neither ever creates or repairs anything — see `egress-policy.ts`.
612
+ //
613
+ // Not optional on this path, and not a copy-paste: a long-lived workspace
614
+ // is the sandbox most likely to be pointed at a network, the NetworkPolicy
615
+ // rather than the bind token is the boundary on its agent port, and a
616
+ // config object refused by one entry point and silently ignored by the
617
+ // other is the exact silent downgrade this translation exists to prevent.
618
+ //
619
+ // Verified against the template this workspace is actually built from:
620
+ // `buildSandboxBody` stamps the pod with THAT template's label and the
621
+ // policy's podSelector matches that label, so a workspace built from a
622
+ // separate workspace template needs its own policy — the task template's
623
+ // does not select it. Deliberately not memoized the way the backend's
624
+ // once-per-backend check is: creating a workspace is a rare, explicit act
625
+ // with nothing to amortise, and re-checking costs one GET.
626
+ if (config.egress) {
627
+ assertEgressPolicyIsEnforceable(config.egress.policy, config.egress.engine ?? 'core')
628
+ await verifyEgressPolicyConfigured(
629
+ client,
630
+ namespace,
631
+ templateName,
632
+ config.egress,
633
+ options.signal,
634
+ )
635
+ }
636
+
637
+ // Read and validate BEFORE anything is created, so a template that cannot
638
+ // carry a workspace fails with nothing to clean up.
639
+ const template = await readSandboxTemplate(client, namespace, templateName, options.signal)
640
+ assertBlockModeWorkspaceDisk(
641
+ `SandboxTemplate ${templateName} in namespace ${namespace}`,
642
+ template.podTemplate,
643
+ template.volumeClaimTemplates,
644
+ )
645
+
646
+ let created = false
647
+ try {
648
+ await client.request(
649
+ 'POST',
650
+ sandboxCollectionPath(namespace),
651
+ // No shutdownTime: a workspace carries no expiry — see the module
652
+ // comment.
653
+ buildSandboxBody({
654
+ namespace,
655
+ name,
656
+ template,
657
+ sandboxTemplateName: templateName,
658
+ ...(config.runtimeClassName !== undefined
659
+ ? { runtimeClassName: config.runtimeClassName }
660
+ : {}),
661
+ }),
662
+ options.signal,
663
+ )
664
+ created = true
665
+ } catch (err) {
666
+ if (!(err instanceof KubernetesConflictError)) throw err
667
+ await adoptExistingWorkspace(
668
+ client,
669
+ namespace,
670
+ name,
671
+ {
672
+ sandboxTemplateName: templateName,
673
+ ...(config.runtimeClassName !== undefined
674
+ ? { runtimeClassName: config.runtimeClassName }
675
+ : {}),
676
+ },
677
+ options.signal,
678
+ )
679
+ }
680
+
681
+ return await openWorkspaceHandle({
682
+ client,
683
+ namespace,
684
+ name,
685
+ workspaceId: options.workspaceId,
686
+ rootDir: options.workingDirectory,
687
+ agentPort,
688
+ readiness,
689
+ created,
690
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
691
+ })
692
+ }
693
+
694
+ /**
695
+ * Take over a Sandbox that already stands under this workspace's name: check
696
+ * that it really is a block-disk workspace AND that it is the one this
697
+ * configuration describes, then wake it if it is asleep.
698
+ *
699
+ * Everything checked here is checked against the object, because on this path
700
+ * the object is not the one this call built. A create POSTs its own body and
701
+ * knows what is in it; an adopt is handed a pod somebody else's process, or
702
+ * last month's configuration, decided the shape of. The two silent losses are
703
+ * the template label and the RuntimeClass — see
704
+ * {@link KubernetesWorkspaceMismatchError}.
705
+ *
706
+ * The refusals all happen BEFORE the resume patch: an object this call will
707
+ * not use is not woken up on the way to being rejected.
708
+ */
709
+ async function adoptExistingWorkspace(
710
+ client: KubernetesClient,
711
+ namespace: string,
712
+ name: string,
713
+ expected: { readonly sandboxTemplateName: string; readonly runtimeClassName?: string },
714
+ signal?: AbortSignal,
715
+ ): Promise<void> {
716
+ const existing = await client.request<SandboxResource>(
717
+ 'GET',
718
+ sandboxPath(namespace, name),
719
+ undefined,
720
+ signal,
721
+ )
722
+ assertBlockModeWorkspaceDisk(
723
+ `Sandbox ${name} in namespace ${namespace}`,
724
+ existing?.spec?.podTemplate,
725
+ existing?.spec?.volumeClaimTemplates,
726
+ )
727
+ assertAdoptedWorkspaceMatchesConfig(name, namespace, existing?.spec?.podTemplate, expected)
728
+ if (existing?.spec?.operatingMode === 'Suspended') {
729
+ await client.request('PATCH', sandboxPath(namespace, name), RESUME_PATCH, signal)
730
+ }
731
+ }
732
+
733
+ /** The two merge patches this module sends, and the only two. */
734
+ const SUSPEND_PATCH = { spec: { operatingMode: 'Suspended' } } as const
735
+ const RESUME_PATCH = { spec: { operatingMode: 'Running' } } as const
736
+
737
+ interface WorkspaceHandleOptions {
738
+ readonly client: KubernetesClient
739
+ readonly namespace: string
740
+ readonly name: string
741
+ readonly workspaceId: string
742
+ readonly rootDir: string
743
+ readonly agentPort: number
744
+ readonly readiness: { readonly timeoutMs: number; readonly pollIntervalMs: number }
745
+ /** Whether THIS call POSTed the object, for the failure message only. */
746
+ readonly created: boolean
747
+ readonly signal?: AbortSignal
748
+ }
749
+
750
+ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<KubernetesWorkspace> {
751
+ const { client, namespace, name, workspaceId, readiness } = options
752
+ const id = name as SandboxId
753
+
754
+ let state: WorkspaceState = 'running'
755
+ let session: KubernetesSandboxHandle | undefined
756
+ /**
757
+ * The uid of the pod `session` is bound to — the agent's token, and the
758
+ * only way a resume can tell the pod it is waiting for from the one it is
759
+ * replacing. Read once per session and never carried across one.
760
+ */
761
+ let podUid: string | undefined
762
+ /**
763
+ * The uid of the pod a suspend patch that LANDED took away, cleared once a
764
+ * resume has bound its replacement.
765
+ *
766
+ * This — not `podUid` — is what a resume excludes; see
767
+ * {@link acquireBoundPod}. It is set from the PATCH rather than from the
768
+ * wait that follows it, so a suspend whose pod outlived `readyTimeoutMs`
769
+ * excludes that pod too: the controller was asked to delete it either
770
+ * way, and a resume arriving while it drains is handed exactly that pod.
771
+ * Every path that sends that patch records it here: `suspend()`, the
772
+ * retirement of a pod that stopped answering, and the cleanup after a
773
+ * failed create or resume — which swallows its own failure, and so
774
+ * records only when the request actually came back. A pod nobody asked
775
+ * the controller to remove has no replacement to wait for, and excluding
776
+ * it would time out a resume whose workspace was perfectly usable.
777
+ */
778
+ let retiredPodUid: string | undefined
779
+ const terminals = new Set<TerminalSession>()
780
+
781
+ /**
782
+ * Lifecycle transitions run one at a time. Two of them racing would
783
+ * interleave a suspend patch with a resume's readiness poll and settle on
784
+ * whichever finished last, which is how a workspace ends up marked running
785
+ * with no pod.
786
+ */
787
+ let queue: Promise<unknown> = Promise.resolve()
788
+ const serialise = <T>(run: () => Promise<T>): Promise<T> => {
789
+ const next = queue.then(run, run)
790
+ queue = next.then(
791
+ () => undefined,
792
+ () => undefined,
793
+ )
794
+ return next
795
+ }
796
+
797
+ /**
798
+ * The suspend and the delete currently in flight, so a second caller
799
+ * AWAITS the one that is running rather than queueing another behind it.
800
+ *
801
+ * Serialising is not the same thing and does not cover this. Now that a
802
+ * terminal state is committed only after its request lands, two
803
+ * concurrent `destroy({ deleteDisk: true })` calls would BOTH be admitted
804
+ * under the queue alone — the second finding nothing yet marked and
805
+ * sending a second DELETE — and two concurrent `suspend()` calls would
806
+ * patch and wait twice over. So the single-flight check sits OUTSIDE the
807
+ * queue, where a caller arriving mid-transition can still see it.
808
+ *
809
+ * The first caller's `signal` is the one the shared request runs under;
810
+ * a second caller's is not consulted, which is what sharing means.
811
+ */
812
+ let pendingSuspend: Promise<void> | undefined
813
+ let pendingDelete: Promise<void> | undefined
814
+
815
+ const deleteSandbox = async (signal?: AbortSignal): Promise<void> => {
816
+ try {
817
+ await client.request('DELETE', sandboxPath(namespace, name), undefined, signal)
818
+ } catch (err) {
819
+ // Already gone is the state DELETE was asking for.
820
+ if (!(err instanceof KubernetesAlreadyGoneError)) throw err
821
+ }
822
+ }
823
+
824
+ const readBinding = async (
825
+ pollSignal: AbortSignal,
826
+ ): Promise<KubernetesSandboxBinding | undefined> =>
827
+ bindingFromSandbox(
828
+ await client.request<SandboxResource>(
829
+ 'GET',
830
+ sandboxPath(namespace, name),
831
+ undefined,
832
+ pollSignal,
833
+ ),
834
+ )
835
+
836
+ /**
837
+ * Wait until the Sandbox is Ready AND the pod behind it is a pod this
838
+ * handle is allowed to bind to, then read that pod's uid.
839
+ *
840
+ * On create there is nothing to exclude and this is one poll plus one
841
+ * read, exactly as it was. On RESUME `previousPodUid` is the pod the
842
+ * workspace was suspended from, and excluding it is the whole point:
843
+ * `Ready` is not a transition signal. The controller leaves the condition
844
+ * True across a resume — upstream says the same of `Suspended` in the
845
+ * other direction — so the very first poll after the Running patch can
846
+ * come back Ready while the only pod under that name is still the old
847
+ * one, not yet deleted and not yet carrying a `deletionTimestamp`. Its
848
+ * uid then reads as perfectly live, and the handle binds a token the new
849
+ * agent will refuse, reported as a flat `unauthorized` with nothing
850
+ * pointing at the race. So the uid is polled, under the SAME deadline as
851
+ * everything else on this path, until it is a different pod's.
852
+ *
853
+ * A failed read is fatal on create and merely "not yet" on resume: for a
854
+ * stretch of every resume there is no live pod at all, and
855
+ * {@link readPodBindToken} answers that by throwing rather than returning
856
+ * undefined. The last such error is carried onto the timeout as `cause`,
857
+ * so a resume that never found a pod still says what it kept seeing.
858
+ */
859
+ const acquireBoundPod = async (
860
+ deadline: OperationDeadline,
861
+ previousPodUid: string | undefined,
862
+ ): Promise<{ binding: KubernetesSandboxBinding; token: string }> => {
863
+ const label = `workspace ${workspaceId} (Sandbox ${name})`
864
+ let lastError: unknown
865
+ while (deadline.remainingMs() > 0) {
866
+ const binding = await pollForBinding(readBinding, deadline, readiness, label)
867
+ let token: string | undefined
868
+ try {
869
+ token = await deadline.run(
870
+ async (tokenSignal) => await readPodBindToken(client, namespace, binding, tokenSignal),
871
+ )
872
+ } catch (err) {
873
+ if (err instanceof OperationDeadlineExpired) break
874
+ if (previousPodUid === undefined) throw err
875
+ lastError = err
876
+ token = undefined
877
+ }
878
+ if (token !== undefined && token !== previousPodUid) return { binding, token }
879
+ try {
880
+ await deadline.delay(readiness.pollIntervalMs)
881
+ } catch (err) {
882
+ if (err instanceof OperationDeadlineExpired) break
883
+ throw err
884
+ }
885
+ }
886
+ throw new Error(
887
+ `kubernetes: workspace ${workspaceId} (Sandbox ${name}) was patched back to operatingMode: Running, but ${readiness.timeoutMs}ms later the only pod behind it was still the one it was suspended from (uid ${previousPodUid}). A resumed pod keeps the sandbox's name and gets a new uid, and that uid is the agent's bind token, so binding to the old pod would present a token the new agent refuses. The Ready condition cannot be waited on instead — the controller leaves it standing across the transition. Raise readyTimeoutMs, or look at why the controller has not replaced the pod.`,
888
+ lastError !== undefined ? { cause: lastError } : undefined,
889
+ )
890
+ }
891
+
892
+ /**
893
+ * Bring up one pod's worth of state: wait for a pod that is not the one
894
+ * being replaced, read its bind token, re-resolve the address, build the
895
+ * transport and prove the guest is deprivileged. Called on create and on
896
+ * every resume, with nothing carried over between them.
897
+ */
898
+ const startSession = async (
899
+ label: string,
900
+ signal?: AbortSignal,
901
+ previousPodUid?: string,
902
+ ): Promise<KubernetesSandboxHandle> => {
903
+ const deadline = new OperationDeadline(
904
+ readiness.timeoutMs,
905
+ `kubernetes workspace ${name} ${label}`,
906
+ signal,
907
+ )
908
+ // Both of these are re-read rather than remembered: a resumed pod keeps
909
+ // the name and changes the uid and the IP, so a handle that reused
910
+ // either would present a token the new agent refuses, at an address
911
+ // whose pod is being deleted.
912
+ const { binding, token } = await acquireBoundPod(deadline, previousPodUid)
913
+ // Recorded before the probe, not after: a probe that refuses suspends
914
+ // this pod, and the resume that follows has to know which pod it is
915
+ // waiting to see replaced.
916
+ podUid = token
917
+ const address = resolveAgentAddress(binding, options.agentPort, token)
918
+ // A box rather than a `let`, so the callback below can name the handle
919
+ // it belongs to before that handle exists. Nothing can call it in
920
+ // between: `release` is reachable only THROUGH the handle.
921
+ const own: { handle?: KubernetesSandboxHandle } = {}
922
+ const inner = buildKubernetesSandbox({
923
+ name,
924
+ rootDir: options.rootDir,
925
+ transport: new KubernetesAgentTransport(address),
926
+ // Deliberately NOT `deleteSandbox` — see {@link retireSession}. On
927
+ // the task path `release` is a DELETE because the object is
928
+ // disposable; here the same callback would erase the caller's disk
929
+ // from a path nobody asked to erase anything.
930
+ release: async (releaseSignal) => {
931
+ await retireSession(own.handle, releaseSignal)
932
+ },
933
+ // No `renew`, and so no lease loop: a workspace carries no expiry.
934
+ })
935
+ own.handle = inner
936
+ // The same probe every task acquire runs, on every resume as well as on
937
+ // create — a resumed pod is a new pod, from a possibly re-pulled image,
938
+ // and "it was deprivileged last week" is not a check.
939
+ await probeSandboxPrivileges(inner, name, resolveProbeTimeoutMs(readiness.timeoutMs), signal)
940
+ return inner
941
+ }
942
+
943
+ /** Kill and await every terminal this handle returned. */
944
+ const reapTerminals = async (): Promise<void> => {
945
+ const open = [...terminals]
946
+ for (const terminal of open) terminal.kill('SIGKILL')
947
+ await Promise.allSettled(open.map((terminal) => terminal.exited))
948
+ terminals.clear()
949
+ }
950
+
951
+ /**
952
+ * Retire one session's pod WITHOUT deleting anything.
953
+ *
954
+ * This is the inner handle's `release` on a workspace, and the difference
955
+ * from the task path is the entire reason that callback is a parameter
956
+ * rather than a DELETE both paths share. `buildKubernetesSandbox` calls
957
+ * `release` on its own initiative: an execution whose cancellation the
958
+ * guest could not confirm (`RemoteCancellationUnknownError` — a wedged
959
+ * agent, a partitioned pod) leaves a command of unknown state in that
960
+ * pod, so the pod stops being reusable and the handle retires it. For a
961
+ * task sandbox retiring IS deleting, because the object is disposable and
962
+ * its disk is scratch. Here it is not: the DELETE cascades to the PVC, and
963
+ * a cancel that went unconfirmed for eight seconds would take a month of
964
+ * the caller's files with it. `deleteDisk: true` is the only thing in this
965
+ * module that removes a disk, and a failure path is not allowed to become
966
+ * a second one — that is the invariant the whole file is built around.
967
+ *
968
+ * So the pod is retired the way `suspend()` retires one, with the same
969
+ * `operatingMode: Suspended` patch, and the workspace is left
970
+ * `suspending`: nothing is admitted, the disk is untouched, and `resume()`
971
+ * brings up a fresh pod. A patch that FAILS is not swallowed — it travels
972
+ * back out through `retire()` as `retirement.accepted === false` on the
973
+ * error the caller is already receiving, which is what that observation
974
+ * exists to say.
975
+ *
976
+ * `retiring` is the handle the callback was built for. When it is not the
977
+ * current session there is nothing to retire and this is a no-op: a resume
978
+ * has already replaced it, or `deleteNow` dropped it on the way to a
979
+ * DELETE — which must not be preceded by a suspend patch, and says so by
980
+ * dropping it.
981
+ *
982
+ * It never touches the transition queue, and must not: `retire()` is
983
+ * awaited inside the failing `exec()`, and that exec can be the privilege
984
+ * probe of the resume currently HOLDING the queue.
985
+ */
986
+ const retireSession = async (
987
+ retiring: KubernetesSandboxHandle | undefined,
988
+ signal?: AbortSignal,
989
+ ): Promise<void> => {
990
+ if (retiring === undefined || retiring !== session) return
991
+ session = undefined
992
+ // Some transition already owns this pod — the suspend that is patching
993
+ // it away, or a create/resume cleanup — and commits its own state when
994
+ // its own request settles. Dropping the session is all there is to do.
995
+ if (state !== 'running') return
996
+ state = 'suspending'
997
+ // Killed but not waited on: the frames go to a pod whose agent has
998
+ // already stopped answering, and whether they are acknowledged must
999
+ // not decide whether the retirement is reported accepted. Every
1000
+ // session dies with the pod either way; this is the ownership contract
1001
+ // being honoured, not a condition of the patch below. The `catch` is
1002
+ // not decoration — a detached chain that rejects with no handler takes
1003
+ // the host process down with it.
1004
+ void reapTerminals().catch(() => undefined)
1005
+ await client.request('PATCH', sandboxPath(namespace, name), SUSPEND_PATCH, signal)
1006
+ // The patch landed, so the controller is taking this pod away and the
1007
+ // next resume must see it replaced rather than bind it.
1008
+ retiredPodUid = podUid
1009
+ }
1010
+
1011
+ /**
1012
+ * True once the pod has actually stopped. The POD is asked, and nothing
1013
+ * else is consulted or believed.
1014
+ *
1015
+ * The cheap-looking alternative — the Sandbox's own `Suspended` condition
1016
+ * — is unusable, and upstream says so itself: "the controller does not
1017
+ * currently remove this condition when the Sandbox is resumed, so a stale
1018
+ * Suspended condition may linger after operatingMode returns to Running.
1019
+ * Consumers should treat Ready as the authoritative signal and not infer
1020
+ * the live operating state from the mere presence of this condition"
1021
+ * (`sandbox_types.go`). Reading it would make the second and every later
1022
+ * suspend of the same workspace return immediately, on a True left behind
1023
+ * by the previous one, while the guest was still running and still
1024
+ * writing to the caller's disk — and a `resume()` issued straight after
1025
+ * such a false suspend could bind to the pod that is about to be deleted.
1026
+ *
1027
+ * A `deletionTimestamp` is not the answer either: it is set the moment the
1028
+ * DELETE is accepted, and the container goes on running until it exits or
1029
+ * `terminationGracePeriodSeconds` expires. Gone (404) or stopped
1030
+ * ({@link isPodStopped}) — those are the only two states that mean the
1031
+ * disk is quiesced.
1032
+ */
1033
+ const isPodRetired = async (signal?: AbortSignal): Promise<boolean> => {
1034
+ try {
1035
+ const pod = await client.request<PodResource>(
1036
+ 'GET',
1037
+ podPath(namespace, name),
1038
+ undefined,
1039
+ signal,
1040
+ )
1041
+ return isPodStopped(pod)
1042
+ } catch (err) {
1043
+ if (err instanceof KubernetesAlreadyGoneError) return true
1044
+ throw err
1045
+ }
1046
+ }
1047
+
1048
+ const awaitPodRetired = async (signal?: AbortSignal): Promise<void> => {
1049
+ const deadline = new OperationDeadline(
1050
+ readiness.timeoutMs,
1051
+ `kubernetes workspace ${name} suspend`,
1052
+ signal,
1053
+ )
1054
+ while (deadline.remainingMs() > 0) {
1055
+ try {
1056
+ if (await deadline.run(isPodRetired)) return
1057
+ await deadline.delay(readiness.pollIntervalMs)
1058
+ } catch (err) {
1059
+ if (err instanceof OperationDeadlineExpired) break
1060
+ throw err
1061
+ }
1062
+ }
1063
+ throw new KubernetesWorkspaceSuspendTimeoutError(workspaceId, name, readiness.timeoutMs)
1064
+ }
1065
+
1066
+ /**
1067
+ * The suspend, minus the queue — every caller here is already inside it.
1068
+ *
1069
+ * `suspending` is entered before the patch and `suspended` only after the
1070
+ * pod is observed stopped, so the two failures each leave the state that
1071
+ * is true: a patch the API server refused leaves the workspace exactly as
1072
+ * it was, still serving calls, and a wait that ran out leaves it refusing
1073
+ * them with the transition still unfinished. Neither can be returned from
1074
+ * by a later `suspend()` as though it had worked.
1075
+ */
1076
+ const suspendNow = async (signal?: AbortSignal): Promise<void> => {
1077
+ if (state === 'deleted') throw new KubernetesSandboxDestroyedError('suspend', name)
1078
+ // `suspending` deliberately falls through: the patch is re-sent and
1079
+ // the pod waited for again. Only a CONFIRMED suspend returns here.
1080
+ if (state === 'suspended') return
1081
+ const before = state
1082
+ // A terminal owns an interactive process tree in a pod that is about
1083
+ // to be taken away, so it is stopped first — and stays stopped even if
1084
+ // the patch below fails. `suspend()` is a declaration that nobody is
1085
+ // using this workspace; killing the sessions that say otherwise is the
1086
+ // point of it rather than a cost of it.
1087
+ await reapTerminals()
1088
+ // From here on nothing new is admitted: the pod is going away, and a
1089
+ // call let through would dial an address that still resolves — the
1090
+ // Service outlives the pod — and hang on a connect timeout naming
1091
+ // nothing. This is NOT the terminal state; a patch that fails puts it
1092
+ // straight back.
1093
+ state = 'suspending'
1094
+ try {
1095
+ await client.request('PATCH', sandboxPath(namespace, name), SUSPEND_PATCH, signal)
1096
+ } catch (err) {
1097
+ // Nothing was changed on the cluster, so nothing is changed here:
1098
+ // the pod is still running and this handle can still serve it.
1099
+ // Marking it suspended would be the defect — every later
1100
+ // suspend() and destroy() would return on that mark without ever
1101
+ // re-sending the patch, and the pod would run until somebody
1102
+ // noticed the bill.
1103
+ state = before
1104
+ throw err
1105
+ }
1106
+ // The patch landed, so this pod is the controller's to remove and the
1107
+ // next resume must see it replaced rather than bind it — whether or
1108
+ // not the wait below is still around when it goes.
1109
+ retiredPodUid = podUid
1110
+ session = undefined
1111
+ await awaitPodRetired(signal)
1112
+ state = 'suspended'
1113
+ }
1114
+
1115
+ const resumeNow = async (signal?: AbortSignal): Promise<void> => {
1116
+ if (state === 'deleted') throw new KubernetesSandboxDestroyedError('resume', name)
1117
+ if (state === 'running') return
1118
+ // The pod a landed suspend patch took away is one this resume must see
1119
+ // replaced rather than bound — see `acquireBoundPod` and
1120
+ // `retiredPodUid`. Where there is none to exclude this is one poll
1121
+ // plus one read, exactly as it is on create.
1122
+ const replacing = retiredPodUid
1123
+ await client.request('PATCH', sandboxPath(namespace, name), RESUME_PATCH, signal)
1124
+ session = await startSessionOrSuspend('resume', signal, replacing)
1125
+ state = 'running'
1126
+ // Bound, probed and serving: the pod that was excluded is one no
1127
+ // answer can name any more, and the next suspend records its own.
1128
+ retiredPodUid = undefined
1129
+ }
1130
+
1131
+ /**
1132
+ * Suspend, sharing one transition with any caller already inside it.
1133
+ *
1134
+ * The single-flight slot is taken before the queue so that a second
1135
+ * `suspend()` — or the `destroy()` that is a suspend — awaits this one
1136
+ * instead of patching and waiting all over again once it finishes. It is
1137
+ * released inside the run, before the promise handed to callers settles,
1138
+ * so a caller that awaits and then suspends again gets a fresh attempt.
1139
+ */
1140
+ const suspendShared = (signal?: AbortSignal): Promise<void> => {
1141
+ pendingSuspend ??= serialise(async () => {
1142
+ try {
1143
+ await suspendNow(signal)
1144
+ } finally {
1145
+ pendingSuspend = undefined
1146
+ }
1147
+ })
1148
+ return pendingSuspend
1149
+ }
1150
+
1151
+ /**
1152
+ * `destroy()` in its default shape: the suspend above, plus the one thing
1153
+ * a destroy owes a caller that a suspend does not — idempotence over a
1154
+ * workspace somebody already deleted.
1155
+ *
1156
+ * `suspendNow` refuses a deleted workspace, and should: asking to suspend
1157
+ * an object that no longer exists is a mistake worth hearing about. But
1158
+ * `destroy()` is the verb a `finally` block calls, and a body that ends
1159
+ * with an explicit `destroy({ deleteDisk: true })` inside such a block
1160
+ * must not then be handed a `KubernetesSandboxDestroyedError` naming an
1161
+ * operation the caller never typed. What a plain `destroy()` asks for has
1162
+ * happened, more thoroughly than it asked.
1163
+ *
1164
+ * The state is read on both sides of the flight on purpose. Before,
1165
+ * for the ordinary sequential case; after, because the delete can land
1166
+ * while this call waits its turn — a `destroy()` racing a `destroy({
1167
+ * deleteDisk: true })` the queue admitted first must be a no-op in that
1168
+ * order too, which is the order it is most likely to be written in.
1169
+ */
1170
+ const destroyBySuspending = async (signal?: AbortSignal): Promise<void> => {
1171
+ // Read through a call on both sides. `state` is assigned from other
1172
+ // closures, which the checker cannot see, so it takes the first
1173
+ // comparison as narrowing the second out of existence — and the second
1174
+ // is the one that matters, because it is the one reading a delete that
1175
+ // landed while this call was queued.
1176
+ const gone = (): boolean => state === 'deleted'
1177
+ if (gone()) return
1178
+ try {
1179
+ await suspendShared(signal)
1180
+ } catch (err) {
1181
+ if (gone() && err instanceof KubernetesSandboxDestroyedError) return
1182
+ throw err
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * DELETE the Sandbox, and with it the Pod, the Service and the PVC.
1188
+ *
1189
+ * The terminal state is committed only once the DELETE has resolved —
1190
+ * an object already gone counts, that being the state DELETE was asking
1191
+ * for. A DELETE that FAILS leaves the state alone and rethrows, so the
1192
+ * caller can retry and the next attempt sends the request again. The
1193
+ * inverse — marking `deleted` first — is how an object outlives every
1194
+ * handle that could have removed it: the failure is thrown once, and every
1195
+ * later `destroy()` resolves immediately on a state nothing established.
1196
+ */
1197
+ const deleteNow = async (destroyOptions?: KubernetesWorkspaceDestroyOptions): Promise<void> => {
1198
+ if (state === 'deleted') return
1199
+ await reapTerminals()
1200
+ const current = session
1201
+ // Dropped BEFORE the handle is torn down, because tearing it down runs
1202
+ // its `release` — which on a workspace is {@link retireSession}, a
1203
+ // SUSPEND patch. A delete does not want one on the way: the object is
1204
+ // going away whole. `retireSession` reads exactly this to know it.
1205
+ session = undefined
1206
+ // And the state moves with it. The pod is being taken away however the
1207
+ // DELETE goes, and the handle that served it is now torn down, so a
1208
+ // DELETE that fails leaves a workspace that admits nothing, says so,
1209
+ // and can be resumed or deleted again — rather than one still calling
1210
+ // itself `running` with no session behind it, which nothing but
1211
+ // another delete could ever get out of.
1212
+ if (state === 'running') state = 'suspending'
1213
+ // Through the inner handle when there is one, so its own terminal
1214
+ // reaping and lifecycle bookkeeping run. The DELETE itself is sent
1215
+ // here either way, exactly once, and it is retryable: the terminal
1216
+ // state below is committed only once it resolves.
1217
+ if (current) await current.destroy(destroyOptions)
1218
+ await deleteSandbox(destroyOptions?.signal)
1219
+ state = 'deleted'
1220
+ }
1221
+
1222
+ /** Delete, sharing one DELETE with any caller already inside it. */
1223
+ const deleteShared = (destroyOptions?: KubernetesWorkspaceDestroyOptions): Promise<void> => {
1224
+ pendingDelete ??= serialise(async () => {
1225
+ try {
1226
+ await deleteNow(destroyOptions)
1227
+ } finally {
1228
+ pendingDelete = undefined
1229
+ }
1230
+ })
1231
+ return pendingDelete
1232
+ }
1233
+
1234
+ /**
1235
+ * Bring a session up, and put the workspace back to sleep if that fails.
1236
+ *
1237
+ * Suspend rather than delete, always: the failure might be a probe refusal
1238
+ * on a workspace whose disk holds a month of a caller's work, and no
1239
+ * failure path in this module is allowed to make that decision. The cost
1240
+ * of being wrong the other way is one suspended Sandbox left standing,
1241
+ * which the caller finds again under the same deterministic name.
1242
+ */
1243
+ const startSessionOrSuspend = async (
1244
+ label: string,
1245
+ signal?: AbortSignal,
1246
+ previousPodUid?: string,
1247
+ ): Promise<KubernetesSandboxHandle> => {
1248
+ try {
1249
+ return await startSession(label, signal, previousPodUid)
1250
+ } catch (err) {
1251
+ // `suspending`, not `suspended`: `runFailureCleanup` swallows its
1252
+ // own failures so that the primary error stays primary, which
1253
+ // means this patch may not have landed and this pod may still be
1254
+ // running. The state says the transition is unfinished, so a
1255
+ // later suspend() re-sends it rather than believing this one.
1256
+ state = 'suspending'
1257
+ session = undefined
1258
+ let retired = false
1259
+ await runFailureCleanup(async (cleanupSignal) => {
1260
+ await client.request('PATCH', sandboxPath(namespace, name), SUSPEND_PATCH, cleanupSignal)
1261
+ retired = true
1262
+ })
1263
+ // Only when the patch came back. A resume that got as far as
1264
+ // binding a new pod and then failed its probe has retired THAT
1265
+ // pod, and the resume after it must wait for the replacement
1266
+ // rather than bind the one this cleanup took away. A cleanup whose
1267
+ // patch never landed retired nothing and has nothing to exclude.
1268
+ if (retired) retiredPodUid = podUid
1269
+ throw err
1270
+ }
1271
+ }
1272
+
1273
+ const admit = (operation: string): KubernetesSandboxHandle => {
1274
+ if (state === 'deleted') throw new KubernetesSandboxDestroyedError(operation, name)
1275
+ if (state !== 'running' || session === undefined) {
1276
+ throw new KubernetesWorkspaceSuspendedError(operation, workspaceId, name)
1277
+ }
1278
+ return session
1279
+ }
1280
+
1281
+ // The first session is brought up here so that `createKubernetesWorkspace`
1282
+ // resolves with a workspace that is Ready, addressed and probed — the same
1283
+ // contract `create()` gives a task sandbox.
1284
+ session = await startSessionOrSuspend(options.created ? 'create' : 'adopt', options.signal)
1285
+ // Whatever the backend's own Sandbox reports, rather than a second copy of
1286
+ // the same constant: it must keep answering after a suspend has taken the
1287
+ // handle it came from away.
1288
+ const environment: SandboxEnvironment = session.environment
1289
+
1290
+ return {
1291
+ id,
1292
+ get status(): SandboxStatus {
1293
+ // A suspended workspace reports 'destroyed' because that is the only
1294
+ // member of the SDK's four-way union meaning "cannot serve a call".
1295
+ // `suspended` below is what tells the recoverable state apart.
1296
+ if (state !== 'running' || session === undefined) return 'destroyed'
1297
+ return session.status
1298
+ },
1299
+ get suspended(): boolean {
1300
+ // `suspending` reads as suspended because that is what a caller
1301
+ // can DO about it: no call is admitted and `resume()` is the way
1302
+ // back. The difference between the two lives where it matters —
1303
+ // in `suspendNow`, which returns early on one and not the other.
1304
+ return state === 'suspended' || state === 'suspending'
1305
+ },
1306
+ rootDir: options.rootDir,
1307
+ environment,
1308
+
1309
+ async exec(
1310
+ command: string,
1311
+ argv?: string[],
1312
+ execOptions?: SandboxExecOptions,
1313
+ ): Promise<SandboxExecResult> {
1314
+ return await admit('exec').exec(command, argv, execOptions)
1315
+ },
1316
+
1317
+ async writeFile(path: string, content: string | Buffer): Promise<void> {
1318
+ await admit('writeFile').writeFile(path, content)
1319
+ },
1320
+
1321
+ async readFile(path: string): Promise<Buffer> {
1322
+ return await admit('readFile').readFile(path)
1323
+ },
1324
+
1325
+ async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
1326
+ return await admit('listFiles').listFiles(rootPath)
1327
+ },
1328
+
1329
+ async openTerminal(terminalOptions: OpenTerminalOptions): Promise<TerminalSession> {
1330
+ const terminal = await admit('openTerminal').openTerminal(terminalOptions)
1331
+ // Tracked HERE as well as by the inner handle, because a suspend
1332
+ // reaps terminals without going through the inner handle's
1333
+ // `destroy()` — the pod is being deleted, and a caller left holding
1334
+ // a session whose exit resolves only when TCP notices is a leak.
1335
+ terminals.add(terminal)
1336
+ // `.catch` after `.finally`, not `void` alone: `exited` belongs to
1337
+ // the CALLER, who may well let it reject, and a bookkeeping chain
1338
+ // hung off it would then reject with no handler and take the host
1339
+ // process down on an unhandled rejection.
1340
+ void terminal.exited
1341
+ .finally(() => {
1342
+ terminals.delete(terminal)
1343
+ })
1344
+ .catch(() => undefined)
1345
+ return terminal
1346
+ },
1347
+
1348
+ async openTcpConnection(
1349
+ connectOptions: SandboxTcpConnectOptions,
1350
+ ): Promise<SandboxTcpConnection> {
1351
+ return await admit('openTcpConnection').openTcpConnection(connectOptions)
1352
+ },
1353
+
1354
+ async suspend(transitionOptions?: KubernetesWorkspaceTransitionOptions): Promise<void> {
1355
+ await suspendShared(transitionOptions?.signal)
1356
+ },
1357
+
1358
+ async resume(transitionOptions?: KubernetesWorkspaceTransitionOptions): Promise<void> {
1359
+ // Serialised, and deliberately without a single-flight slot of its
1360
+ // own. The two terminal transitions need one because each commits
1361
+ // its state only after the cluster confirms it, so a second caller
1362
+ // admitted behind the first finds nothing marked and re-sends;
1363
+ // `resumeNow` commits `running` at the END of a transition that
1364
+ // leaves the workspace usable, and returns early on it, so the
1365
+ // second caller finds the work done. Give resume a state it
1366
+ // early-returns on before the cluster confirms it and it will need
1367
+ // a slot as much as they do.
1368
+ await serialise(async () => await resumeNow(transitionOptions?.signal))
1369
+ },
1370
+
1371
+ async destroy(destroyOptions?: KubernetesWorkspaceDestroyOptions): Promise<void> {
1372
+ if (destroyOptions?.deleteDisk !== true) {
1373
+ // The default, and the whole point of the default: there is no
1374
+ // delete-compute-keep-disk verb, so the closest thing to one is
1375
+ // a suspend, and `destroy()` in a `finally` must not erase a
1376
+ // workspace nobody asked to erase. It shares the suspend's
1377
+ // single flight, so `destroy()` racing `suspend()` is one
1378
+ // transition rather than two — and it stays idempotent over a
1379
+ // workspace already deleted, where `suspend()` itself refuses.
1380
+ await destroyBySuspending(destroyOptions?.signal)
1381
+ return
1382
+ }
1383
+ await deleteShared(destroyOptions)
1384
+ },
1385
+ }
1386
+ }