@molecule/api-code-sandbox-flyio 1.3.6 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
3
  Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
4
  Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
5
  To change this document, edit the module-level JSDoc in src/index.ts.
6
- Generated: 2026-10-26T04:22:26.020Z
6
+ Generated: 2026-11-04T14:19:55.149Z
7
7
  -->
8
8
 
9
9
  # @molecule/api-code-sandbox-flyio
@@ -16,6 +16,10 @@ SCALE-TO-ZERO: `sleep()` maps to Fly's `suspend`, which snapshots the microVM's
16
16
  memory to disk, and a suspended Machine bills for storage only. Waking is a
17
17
  resume from that snapshot rather than a cold boot.
18
18
 
19
+ `FLY_REGION` is the API Machine's own location and is IGNORED for tenant
20
+ placement. Set the provider's `region` default, or pass one create's
21
+ `SandboxConfig.region` for a project whose placement was recorded at creation.
22
+
19
23
  > **Auto-generated, AI-first package reference** for the [molecule.dev](https://molecule.dev) ecosystem.
20
24
  > It is written to be read by coding agents as much as by people, and is generated from this
21
25
  > package's source — edit `src/index.ts` JSDoc, not this file.
@@ -87,13 +91,15 @@ npm install @molecule/api-code-sandbox-flyio @aws-sdk/client-s3 @aws-sdk/s3-requ
87
91
  Options for a single Fly API request.
88
92
 
89
93
  ```typescript
90
- interface FlyRequestOptions {
94
+ interface FlyRequestOptions<T = unknown> {
91
95
  /** HTTP method. Defaults to `GET`. */
92
96
  method?: string
93
97
  /** JSON request body. Omitted entirely when undefined. */
94
98
  body?: unknown
95
99
  /** Per-request timeout in ms. Defaults to the client's configured timeout. */
96
100
  timeoutMs?: number
101
+ /** Cancels in-flight provisioning when the owning lifecycle lock is lost. */
102
+ signal?: AbortSignal
97
103
  /** Max attempts for a retryable failure. Defaults to 4. Pass 1 to disable retries. */
98
104
  attempts?: number
99
105
  /**
@@ -108,6 +114,19 @@ interface FlyRequestOptions {
108
114
  * under exec-burst load, not a real absence, so it is worth repeating.
109
115
  */
110
116
  retryStatuses?: number[]
117
+ /**
118
+ * Restricts retries opted into by `retryStatuses` using the response error.
119
+ * Return false to surface that response immediately. The client's standard
120
+ * transport, 429 and 5xx retries are unaffected.
121
+ */
122
+ retryWhen?: (error: FlyApiError) => boolean
123
+ /**
124
+ * Reconciles a retryable failure before the client backs off or throws.
125
+ * Returning a value completes the request; `undefined` keeps the normal
126
+ * retry/exhaustion path. This lets an ambiguous write look for the resource
127
+ * it may have created without adding a second POST retry loop.
128
+ */
129
+ reconcile?: (error: FlyApiError) => Promise<T | undefined>
111
130
  }
112
131
  ```
113
132
 
@@ -176,6 +195,183 @@ interface EgressProbeContext {
176
195
  }
177
196
  ```
178
197
 
198
+ #### `FlyCreateVolumeIntent`
199
+
200
+ A volume name generated for one attempt, or an existing durable workspace.
201
+
202
+ ```typescript
203
+ interface FlyCreateVolumeIntent {
204
+ /** Fly's resource ID. Once known, this—not the name—is the ownership key. */
205
+ id?: string
206
+ /** The current Fly name, unique for a newly-created attempt volume. */
207
+ name: string
208
+ /** Caller-level workspace identity, stable across create attempts. */
209
+ logicalName?: string
210
+ /** Once bound, only explicit destruction or a reference-checked failed-create sweep may delete it. */
211
+ workspace: boolean
212
+ /** False until an ambiguous create is adopted or definitely rejected. */
213
+ resolved?: boolean
214
+ /** Recorded before this attempt's create POST; automatic deletion also requires a resolved Fly ID. */
215
+ createdByAttemptId?: string
216
+ /** Recorded when this attempt adopted an existing/listed volume; never automatic-delete authority. */
217
+ adoptedByAttemptId?: string
218
+ }
219
+ ```
220
+
221
+ #### `FlyCreateAddressIntent`
222
+
223
+ An address on another Fly app which must be released before forgetting intent.
224
+
225
+ ```typescript
226
+ interface FlyCreateAddressIntent {
227
+ targetApp: string
228
+ network: string
229
+ ip?: string
230
+ /** True only after Fly returned this allocation to this attempt. */
231
+ owned: boolean
232
+ /** False while a lost response leaves allocation ownership unknown. */
233
+ resolved: boolean
234
+ }
235
+ ```
236
+
237
+ #### `FlyCreateAttempt`
238
+
239
+ The complete durable responsibility for one create attempt.
240
+
241
+ ```typescript
242
+ interface FlyCreateAttempt {
243
+ attemptId: string
244
+ projectId: string
245
+ appName: string
246
+ state: FlyCreateAttemptState
247
+ /** Monotonic fencing token, incremented whenever lifecycle ownership is reacquired. */
248
+ generation?: number
249
+ sandboxId?: string
250
+ /** Fly definitively rejected the Machine POST; an unknown outcome is never marked. */
251
+ machineCreateFailed?: boolean
252
+ /**
253
+ * Durable Machine POST lifecycle. New attempts start at not-started; pending is
254
+ * written before dispatch. NULL is a legacy unknown, never proof of no POST.
255
+ */
256
+ machineCreateOutcome?: 'not-started' | 'pending' | 'rejected' | 'unknown' | 'accepted'
257
+ /** The exact Machine ID returned by this attempt's accepted POST response, or an observation without delete provenance. */
258
+ machineId?: string
259
+ /** Accepted-POST provenance; name, metadata, sandboxId and adoption never supply it. */
260
+ machineCreatedByAttemptId?: string
261
+ /** This attempt's app-create intent, recorded before the POST. */
262
+ appCreatedByAttemptId?: string
263
+ /** True only when the app POST itself was accepted; a pending/unknown create cannot be deleted. */
264
+ appCreateResolved?: boolean
265
+ /** True when explicit destruction identity-validated and adopted a pre-journal Machine. */
266
+ machineAdopted?: boolean
267
+ volumes: FlyCreateVolumeIntent[]
268
+ addresses: FlyCreateAddressIntent[]
269
+ }
270
+ ```
271
+
272
+ #### `FlyCreateAttemptJournal`
273
+
274
+ Application-supplied durable journal used by create and its reconciler.
275
+ A journal-aware writer that ignores provenance must never run beside this
276
+ contract; {@link assertCreateAllowed} is the durable rollout boundary.
277
+
278
+ ```typescript
279
+ interface FlyCreateAttemptJournal {
280
+ begin(
281
+ attempt: Pick<FlyCreateAttempt, 'attemptId' | 'projectId' | 'appName'> & {
282
+ ownerProjectId?: string
283
+ /** Legacy adoption persists its observed serving state and identity in the INSERT. */
284
+ state?: FlyCreateAttemptState
285
+ sandboxId?: string
286
+ machineAdopted?: boolean
287
+ },
288
+ ): Promise<void>
289
+ /** Refuse provisioning until pre-journal and provenance-ignoring writers have drained. */
290
+ assertCreateAllowed(): Promise<void>
291
+ /** Re-read one attempt, including after a lifecycle lock has been acquired. */
292
+ get(attemptId: string): Promise<FlyCreateAttempt | null>
293
+ /** Re-read every durable attempt for an app, including creating/serving attempts. */
294
+ forApp(appName: string): Promise<FlyCreateAttempt[]>
295
+ /** Find the durable workspace binding for a logical caller workspace. */
296
+ findWorkspace(projectId: string, logicalName: string): Promise<FlyCreateVolumeIntent | null>
297
+ /** Record app-create intent/acceptance, or clear it when the app was adopted. */
298
+ recordAppProvenance(
299
+ attemptId: string,
300
+ generation: number,
301
+ outcome: 'pending' | 'created' | 'adopted',
302
+ ): Promise<void>
303
+ /** Record an intent or transfer a Fly-ID binding to this attempt. */
304
+ recordVolume(attemptId: string, volume: FlyCreateVolumeIntent): Promise<void>
305
+ markVolumeWorkspace(attemptId: string, name: string, workspace?: boolean): Promise<void>
306
+ recordAddress(attemptId: string, address: FlyCreateAddressIntent): Promise<void>
307
+ markState(attemptId: string, state: FlyCreateAttemptState, sandboxId?: string): Promise<void>
308
+ /** Record that explicit destruction adopted this pre-journal Machine. */
309
+ markMachineAdopted(attemptId: string): Promise<void>
310
+ /** Record POST intent before dispatch and an ambiguous outcome before recovery reads. */
311
+ recordMachineCreateOutcome(
312
+ attemptId: string,
313
+ generation: number,
314
+ outcome: 'pending' | 'unknown',
315
+ ): Promise<void>
316
+ /** Persist a recovered exact ID as observation only, never accepted-POST authority. */
317
+ recordMachineObservation(attemptId: string, generation: number, machineId: string): Promise<void>
318
+ /** Record only the exact ID from this generation's accepted Machine POST response. */
319
+ recordMachineProvenance(attemptId: string, generation: number, machineId: string): Promise<void>
320
+ /** Record a definitive rejection only while this create generation still owns the attempt. */
321
+ markMachineCreateFailed(attemptId: string, generation: number): Promise<void>
322
+ /** Throw unless this generation is still the attempt's durable fencing token. */
323
+ assertGeneration(attemptId: string, generation: number): Promise<void>
324
+ /** Atomically retire this exact generation while its lifecycle lock session is live. */
325
+ retire(attemptId: string, generation: number): Promise<void>
326
+ /** Re-authorize one destructive request against the retired row on the lock session. */
327
+ authorizeDestruction(attemptId: string, generation: number): Promise<void>
328
+ /** Failed/abandoned attempts, plus creating attempts whose updatedAt is at/before cutoff. */
329
+ reclaimable(cutoff: Date): Promise<FlyCreateAttempt[]>
330
+ /** Acquire the same lifecycle lock the application uses for create/destroy. */
331
+ withLock?<T>(
332
+ attempt: FlyCreateAttempt,
333
+ body: (signal: AbortSignal, generation: number) => Promise<T>,
334
+ ): Promise<T>
335
+ forget(attemptId: string, generation?: number): Promise<void>
336
+ }
337
+ ```
338
+
339
+ #### `FlyCreateReconcileSummary`
340
+
341
+ Attempts forgotten after completed cleanup, and attempts retained after errors.
342
+
343
+ ```typescript
344
+ interface FlyCreateReconcileSummary {
345
+ deleted: number
346
+ failed: number
347
+ }
348
+ ```
349
+
350
+ #### `FlyCreateAttemptProvider`
351
+
352
+ Fly-specific lifecycle capabilities exposed by the provider.
353
+
354
+ ```typescript
355
+ interface FlyCreateAttemptProvider {
356
+ /** Explicit retirement is fenced by the caller's live lifecycle lock. */
357
+ destroy(id: string, signal: AbortSignal): Promise<void>
358
+ /** Deliberately retire exact recorded resources for one shared-app attempt. */
359
+ destroyAttempt(attemptId: string, signal: AbortSignal): Promise<void>
360
+ /** Per-project app retirement is fenced by the caller's live lifecycle lock. */
361
+ destroyApp(
362
+ projectId: string,
363
+ expectedAppName: string | undefined,
364
+ signal: AbortSignal,
365
+ ): Promise<void>
366
+ reconcileCreateAttempts(options?: {
367
+ now?: number
368
+ createTimeoutMs?: number
369
+ /** Restrict a pass to one durable attempt. Every attempt is still locked. */
370
+ attemptId?: string
371
+ }): Promise<FlyCreateReconcileSummary>
372
+ }
373
+ ```
374
+
179
375
  #### `StoredObject`
180
376
 
181
377
  One object as this bond needs to see it.
@@ -288,167 +484,95 @@ Every option is also readable from an environment variable (see
288
484
 
289
485
  ```typescript
290
486
  interface FlyioConfig {
291
- /**
292
- * Fly API token (sent as `Authorization: Bearer <token>`). Falls back to
293
- * `FLY_API_TOKEN`, then `FLY_ACCESS_TOKEN`. Required — the provider throws a
294
- * named error on the first API call if none is resolvable.
295
- */
487
+ /** Fly API token (sent as `Authorization: Bearer <token>`). */
296
488
  apiToken?: string
297
- /**
298
- * Machines API base URL, INCLUDING the `/v1` path segment. Defaults to
299
- * `FLY_API_HOSTNAME` (with `/v1` appended when the value has no path) or
300
- * `https://api.machines.dev/v1`. From inside a Fly private network the
301
- * documented internal endpoint is `http://_api.internal:4280/v1`.
302
- */
489
+ /** Machines API base URL, INCLUDING the `/v1` path segment. */
303
490
  apiUrl?: string
304
- /**
305
- * Fly organization slug that owns the sandbox apps. Falls back to
306
- * `FLY_ORG_SLUG`, then `personal`. Required for app creation and for
307
- * {@link FlyioConfig.appPerProject} listing.
308
- */
491
+ /** Fly organization slug that owns the sandbox apps. */
309
492
  orgSlug?: string
310
- /**
311
- * Shared Fly app that holds every sandbox Machine. Only used when
312
- * `appPerProject` is `false`. Falls back to `FLY_SANDBOX_APP`.
313
- *
314
- * A shared app puts every tenant on ONE 6PN private network, where each
315
- * sandbox can reach every other sandbox's dev-server ports by private IPv6.
316
- * That is the same cross-tenant exposure the Docker bond's `bridge` network
317
- * has, so this mode is REFUSED in production.
318
- */
493
+ /** Shared Fly app that holds every sandbox Machine. */
319
494
  appName?: string
320
- /**
321
- * Name prefix for the per-project Fly app when `appPerProject` is `true`
322
- * (default). The app is `<appPrefix>-<sanitized projectId>`. Falls back to
323
- * `FLY_SANDBOX_APP_PREFIX`, then `mol-sandbox`.
324
- */
495
+ /** Name prefix for the per-project Fly app when `appPerProject` is `true` (default). */
325
496
  appPrefix?: string
326
- /**
327
- * Create one Fly app — on its own custom 6PN private network — per project,
328
- * so tenants are network-isolated from each other. Defaults to `true`.
329
- * Set `false` (or `FLY_SANDBOX_APP_PER_PROJECT=false`) only for a
330
- * single-tenant deployment; it is refused in production.
331
- */
497
+ /** Create one Fly app — on its own custom 6PN private network — per project, so tenants are network-isolated from each other. */
332
498
  appPerProject?: boolean
333
- /**
334
- * Custom 6PN network name for a per-project app. Defaults to the app name, so
335
- * every project lands on its own private network. Ignored when
336
- * `appPerProject` is `false`. A Fly app's network CANNOT be changed after
337
- * creation.
338
- */
499
+ /** Custom 6PN network name for a per-project app. */
339
500
  network?: string
340
- /** Fly region for Machines and volumes (e.g. `iad`). Falls back to `FLY_REGION`, then `iad`. */
501
+ /** Default Fly region for Machines and volumes (e.g. `iad`). */
341
502
  region?: string
342
- /**
343
- * OCI image every sandbox Machine runs. Must be pullable by Fly — a tag in
344
- * the org's `registry.fly.io` repository or a public registry. A LOCAL
345
- * `molecule-sandbox:latest` is not reachable by Fly; push it first. Falls
346
- * back to `FLY_SANDBOX_IMAGE`, then `registry.fly.io/molecule-sandbox:latest`.
347
- */
503
+ /** OCI image every sandbox Machine runs. */
348
504
  baseImage?: string
349
- /** Default vCPU count (`guest.cpus`). Defaults to 1. */
505
+ /** Default vCPU count (`guest.cpus`). */
350
506
  defaultCpu?: number
351
- /** Default `guest.cpu_kind` — `shared` or `performance`. Defaults to `shared`. */
507
+ /** Default `guest.cpu_kind` — `shared` or `performance`. */
352
508
  defaultCpuKind?: string
353
- /** Default memory in MB (`guest.memory_mb`). Defaults to 1024. */
509
+ /** Default memory in MB (`guest.memory_mb`). */
354
510
  defaultMemoryMB?: number
355
- /**
356
- * Size in GB of the volume created for a sandbox's `/workspace` when
357
- * `SandboxConfig.volumeName` is set. Fly volumes are sized in whole GB, so the
358
- * core's `resources.diskMB` is rounded UP to the next GB. Defaults to 10.
359
- */
511
+ /** Size in GB of the volume created for a sandbox's `/workspace` when `SandboxConfig.volumeName` is set. */
360
512
  defaultVolumeGB?: number
361
- /** Internal port the preview service forwards to (the Vite dev server). Defaults to 5173. */
513
+ /** Internal port the preview service forwards to (the Vite dev server). */
362
514
  previewPort?: number
363
- /**
364
- * Preview URL template. Placeholders `{app}`, `{machineId}` and `{port}` are
365
- * all replaced globally. Defaults to `https://{app}.fly.dev`.
366
- *
367
- * For a control plane that runs on the SAME Fly 6PN, the private form
368
- * `http://{machineId}.vm.{app}.internal:{port}` reaches the Machine with no
369
- * public exposure at all — but it does NOT work across custom 6PNs, which is
370
- * exactly what `appPerProject` creates. See the module `@remarks`.
371
- */
515
+ /** Preview URL template. */
372
516
  previewUrlTemplate?: string
373
- /**
374
- * Attach a public Fly Proxy service (`80` → http, `443` → tls+http) forwarding
375
- * to {@link FlyioConfig.previewPort}. Defaults to `true`. Set `false` for a
376
- * fully private sandbox reached only over 6PN/Flycast.
377
- */
517
+ /** Attach a public Fly Proxy service (`80` → http, `443` → tls+http) forwarding to {@link FlyioConfig.previewPort}. */
378
518
  publicService?: boolean
379
- /**
380
- * Fly Proxy idle behaviour for the preview service. Defaults to `'off'` —
381
- * never idle the Machine — because the autostop timer counts the PROXY
382
- * service's idle time, not the workload's, so a sandbox with nobody looking at
383
- * its preview is suspended out from under a build that is still running.
384
- * `suspend` is the scale-to-zero mapping (resume from a memory snapshot on the
385
- * next request) and `stop` is a full stop with a cold boot on wake; opt into
386
- * either deliberately.
387
- *
388
- * This is the provider's STEADY-STATE policy, reported as
389
- * `SandboxProvider.defaultIdleAction`. A caller can override it for one sandbox
390
- * with `SandboxConfig.idleAction` and restore this value afterwards with
391
- * `Sandbox.setIdleAction` — which is what a production deploy does so its
392
- * destination cannot suspend mid-copy.
393
- */
519
+ /** Fly Proxy idle behaviour for the preview service. */
394
520
  autostop?: 'off' | 'stop' | 'suspend'
395
- /**
396
- * Allocate a shared Anycast IPv4 for a newly-created app so `<app>.fly.dev`
397
- * serves traffic. Defaults to `false` (opt-in). See the module `@remarks` —
398
- * the accepted `type` values for the IP-assignment endpoint are NOT enumerated
399
- * in Fly's OpenAPI specification.
400
- */
521
+ /** Allocate a shared Anycast IPv4 for a newly-created app so `<app>.fly.dev` serves traffic. */
401
522
  assignSharedIpv4?: boolean
402
- /** IP type requested when {@link FlyioConfig.assignSharedIpv4} is on. Defaults to `shared_v4`. */
523
+ /** IP type requested when {@link FlyioConfig.assignSharedIpv4} is on. */
403
524
  ipAssignmentType?: string
404
- /** Prefix for the Machine metadata keys this provider owns. Defaults to `molecule-sandbox`. */
525
+ /** Prefix for the Machine metadata keys this provider owns. */
405
526
  metadataPrefix?: string
406
- /** Timeout for a single Machines API request, in ms. Defaults to 30000. */
527
+ /** Durable create-attempt ownership. */
528
+ createAttemptJournal?: FlyCreateAttemptJournal
529
+ /** Timeout for a single Machines API request, in ms. */
407
530
  requestTimeoutMs?: number
408
- /**
409
- * TOTAL budget, in seconds, that `create()`/`start()`/`wake()` block waiting
410
- * for the Machine to actually reach `started`. Fly's `GET .../wait` blocks
411
- * for at most 60 seconds per call, so a larger budget is spent as consecutive
412
- * wait rounds. Defaults to 180 — a Machine whose image is not yet cached on
413
- * its host (every first boot after an image push) pulls it before starting,
414
- * and that alone can exceed a single 60 s round.
415
- *
416
- * Without this wait, `start()` resolves the moment Fly ACCEPTS the request,
417
- * and the caller's very next `exec` hits a Machine that is not running yet.
418
- */
531
+ /** TOTAL budget, in seconds, that `create()`/`start()`/`wake()` block waiting for the Machine to actually reach `started`. */
419
532
  startTimeoutSeconds?: number
420
- /**
421
- * Ports a sandbox Machine may open outbound connections on. Setting this makes
422
- * the provider apply a Fly **network policy** to every app it provisions;
423
- * leaving it unset applies no policy at all, and `verifyEgress()` will then
424
- * observe (correctly) that egress is `open`. Falls back to
425
- * `FLY_SANDBOX_EGRESS_ALLOWED_PORTS` (`tcp:3128,udp:53`).
426
- *
427
- * Read {@link https://fly.io/docs/machines/guides-examples/network-policies/}
428
- * before choosing a value, because the mechanism is narrower than it looks:
429
- * rules match protocol and port ONLY — no host, no CIDR, no ranges — so this
430
- * can never be a host allowlist. `tcp:443` lets a sandbox reach EVERY host on
431
- * the internet that listens on 443. To get a host allowlist, allow only the
432
- * port of an egress proxy you control and route sandbox traffic through it.
433
- *
434
- * An empty array is rejected rather than treated as "deny all": Fly documents
435
- * the deny default as a consequence of an `allow` rule existing, and says
436
- * nothing about a rule with no ports.
437
- */
533
+ /** Ports a sandbox Machine may open outbound connections on. */
438
534
  egressAllowedPorts?: FlyNetworkPolicyPort[]
439
- /** Name of the egress policy this provider owns on each app. Defaults to `molecule-sandbox-egress`. */
535
+ /** Name of the egress policy this provider owns on each app. */
440
536
  egressPolicyName?: string
441
- /**
442
- * Apps every sandbox must be able to reach ACROSS its per-project 6PN — the
443
- * tenant Postgres cluster and the control plane's egress proxy. For each one,
444
- * this provider allocates a **Flycast** private address into the project's own
445
- * network when the project's app is created, and releases it on `destroy()`.
446
- * Falls back to `FLY_SANDBOX_PRIVATE_SERVICES` (`<app>:<port>` pairs).
447
- *
448
- * Without this, a project with a database DOES NOT WORK on Fly: per-project
449
- // … truncated — the full declaration is in node_modules/@molecule/api-code-sandbox-flyio/dist/types.d.ts
537
+ /** Apps every sandbox must be able to reach ACROSS its per-project 6PN — the tenant Postgres cluster and the control plane's egress proxy. */
538
+ privateServices?: FlyPrivateService[]
539
+ /** Per-create regional overrides for {@link FlyioConfig.privateServices}. */
540
+ privateServicesByRegion?: Record<string, FlyPrivateService[]>
541
+ /** `type` sent to `POST /apps/{app}/ip_assignments` when allocating a Flycast address. */
542
+ privateIpAssignmentType?: string
543
+ /** Literal `ip:port` targets `verifyEgress()` attempts raw TCP connections to. */
544
+ egressProbeTargets?: string[]
545
+ /** Per-connection timeout for the egress probe, in ms. */
546
+ egressProbeTimeoutMs?: number
547
+ /** Image the throwaway `verifyEgress()` probe Machine runs. */
548
+ egressProbeImage?: string
549
+ /** Bucket holding sandbox templates. */
550
+ templateBucket?: string
551
+ /** S3 endpoint for {@link FlyioConfig.templateBucket}, e.g. Tigris's `https://t3.storage.dev`. */
552
+ templateEndpoint?: string
553
+ /** Region used to sign template-store requests. */
554
+ templateRegion?: string
555
+ /** Access key for the template store. */
556
+ templateAccessKeyId?: string
557
+ /** Secret key for the template store. */
558
+ templateSecretAccessKey?: string
559
+ /** Session token for temporary template-store credentials. */
560
+ templateSessionToken?: string
561
+ /** Key prefix every template object lives under. */
562
+ templatePrefix?: string
563
+ /** Address the template bucket path-style (`https://endpoint/bucket/key`) instead of virtual-host style. */
564
+ templateForcePathStyle?: boolean
565
+ /** Lifetime of the presigned capture/restore URL handed to a sandbox, in seconds. */
566
+ templateUrlExpirySeconds?: number
567
+ /** Wall-clock budget for one capture or restore transfer, in ms. */
568
+ templateTransferTimeoutMs?: number
569
+ /** Largest template archive this provider will store, in bytes. */
570
+ templateMaxArchiveBytes?: number
571
+ }
450
572
  ```
451
573
 
574
+ _(Member comments are shortened to their first sentence to fit; the full declaration is in `node_modules/@molecule/api-code-sandbox-flyio/dist/types.d.ts`.)_
575
+
452
576
  #### `ProcessEnv`
453
577
 
454
578
  Environment variables the Fly.io sandbox provider reads. Every one is
@@ -470,7 +594,11 @@ interface ProcessEnv {
470
594
  FLY_SANDBOX_APP_PREFIX?: string
471
595
  /** `false` disables per-project apps (refused in production). */
472
596
  FLY_SANDBOX_APP_PER_PROJECT?: string
473
- /** Fly region for Machines and volumes (default `iad`). */
597
+ /**
598
+ * The API Machine's own region, not a tenant-placement setting; ignored.
599
+ *
600
+ * @deprecated Use `FlyioConfig.region` or one create's `SandboxConfig.region`.
601
+ */
474
602
  FLY_REGION?: string
475
603
  /** OCI image for sandbox Machines. */
476
604
  FLY_SANDBOX_IMAGE?: string
@@ -555,16 +683,20 @@ interface FlyPrivateService {
555
683
  An IP assignment, as returned by `POST`/`GET /v1/apps/{app}/ip_assignments`
556
684
  (`IPAssignment` in https://docs.machines.dev/openapi.json).
557
685
 
558
- Note what is NOT here: the assignment's **network**. Fly's own schema is
559
- `{created_at, ip, region, service_name, shared}`, so a listing cannot tell you
560
- which 6PN a private address serves — which is why this provider records the
561
- addresses it allocated in the sandbox Machine's metadata instead of
562
- rediscovering them.
686
+ Current Fly schema includes the assignment's network identity, allowing a
687
+ lost allocation response to be resolved by target app and 6PN. Durable
688
+ records remain authoritative for what this provider must release.
563
689
 
564
690
  ```typescript
565
691
  interface FlyIpAssignment {
566
692
  /** The allocated address. Required for a later `DELETE .../ip_assignments/{ip}`. */
567
693
  ip?: string
694
+ /**
695
+ * Private 6PN identity. GET returns `{name, org_slug}` (null for non-Flycast
696
+ * assignments); only the POST request body uses separate string fields.
697
+ * The string form remains accepted for older cached/test responses.
698
+ */
699
+ network?: string | { name?: string | null; org_slug?: string | null } | null
568
700
  /** Region the address was allocated in, when Fly reports one. */
569
701
  region?: string
570
702
  /** Service the address is bound to, when the assignment names one. */
@@ -776,6 +908,8 @@ interface FlyVolume {
776
908
  size_gb?: number
777
909
  region?: string
778
910
  attached_machine_id?: string
911
+ /** RFC 3339 creation time; required as proof before an old attempt is reaped. */
912
+ created_at?: string
779
913
  }
780
914
  ```
781
915
 
@@ -802,6 +936,14 @@ Runs one exec call against a Machine. Injected so this module stays transport-fr
802
936
  type RawExec = (command: string[], timeoutSeconds: number) => Promise<FlyExecResponse>
803
937
  ```
804
938
 
939
+ #### `FlyCreateAttemptState`
940
+
941
+ Durable lifecycle state; only failed/abandoned attempts are automatically reclaimed.
942
+
943
+ ```typescript
944
+ type FlyCreateAttemptState = 'creating' | 'serving' | 'failed' | 'abandoned'
945
+ ```
946
+
805
947
  #### `FlyMachineState`
806
948
 
807
949
  A Fly Machine state, as documented at https://fly.io/docs/machines/machine-states/.
@@ -924,7 +1066,7 @@ class FlyApiClient {
924
1066
  * @throws {FlyApiError} On a non-2xx status that is not listed in `nullOn`,
925
1067
  * or when every retryable attempt is exhausted.
926
1068
  */
927
- async request<T>(path: string, options: FlyRequestOptions = {}): Promise<T | null> {
1069
+ async request<T>(path: string, options: FlyRequestOptions<T> = {}): Promise<T | null> {
928
1070
  const method = options.method ?? 'GET'
929
1071
  const attempts = options.attempts ?? DEFAULT_ATTEMPTS
930
1072
  const token = this.token()
@@ -939,9 +1081,13 @@ class FlyApiClient {
939
1081
 
940
1082
  let lastError: FlyApiError | undefined
941
1083
  for (let attempt = 1; attempt <= attempts; attempt++) {
1084
+ if (options.signal?.aborted) throw options.signal.reason
942
1085
  // Pace EVERY network call (including retries) through the process-wide
943
1086
  // limiter so a boot's burst of exec calls never trips Fly's account 429.
944
1087
  await paceFlyRequest(this.sleep)
1088
+ // Pacing is cancellable too: ownership protection starts only when this
1089
+ // request really enters fetch, never while it is still waiting for a slot.
1090
+ if (options.signal?.aborted) throw options.signal.reason
945
1091
  const controller = new AbortController()
946
1092
  const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? this.timeoutMs)
947
1093
  // Initialized to 0 ("no answer received"), which is the value that
@@ -949,18 +1095,22 @@ class FlyApiClient {
949
1095
  let status = 0
950
1096
  let retryAfter: string | null = null
951
1097
  try {
952
- const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
1098
+ const pendingResponse = this.fetchImpl(`${this.baseUrl}${path}`, {
953
1099
  method,
954
1100
  headers: {
955
1101
  Authorization: `Bearer ${token}`,
956
1102
  ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
957
1103
  },
958
1104
  ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
959
- signal: controller.signal,
1105
+ signal: options.signal
1106
+ ? AbortSignal.any([controller.signal, options.signal])
1107
+ : controller.signal,
960
1108
  })
1109
+ const response = await pendingResponse
961
1110
  status = response.status
962
1111
  retryAfter = response.headers?.get?.('retry-after') ?? null
963
1112
  const text = await response.text()
1113
+ if (options.signal?.aborted) throw options.signal.reason
964
1114
 
965
1115
  if (status >= 200 && status < 300) {
966
1116
  if (!text.trim()) return null
@@ -976,6 +1126,7 @@ class FlyApiClient {
976
1126
  if (options.nullOn?.includes(status)) return null
977
1127
  lastError = new FlyApiError(method, path, status, text.slice(0, 500))
978
1128
  } catch (error) {
1129
+ if (options.signal?.aborted) throw options.signal.reason
979
1130
  if (error instanceof FlyApiError) throw error
980
1131
  // Transport-level failure (abort/timeout, DNS, connection reset) — no
981
1132
  // status was ever received, so it is modelled as status 0 and retried.
@@ -990,8 +1141,16 @@ class FlyApiClient {
990
1141
  clearTimeout(timer)
991
1142
  }
992
1143
 
993
- const retryable =
994
- isRetryableStatus(status) || (options.retryStatuses?.includes(status) ?? false)
1144
+ const extraRetry = Boolean(
1145
+ options.retryStatuses?.includes(status) &&
1146
+ lastError &&
1147
+ (options.retryWhen?.(lastError) ?? true),
1148
+ )
1149
+ const retryable = isRetryableStatus(status) || extraRetry
1150
+ if (retryable && lastError && options.reconcile) {
1151
+ const recovered = await options.reconcile(lastError)
1152
+ if (recovered !== undefined) return recovered
1153
+ }
995
1154
  if (!retryable || attempt === attempts) break
996
1155
  const delay = retryDelayMs(attempt, retryAfter)
997
1156
  logger.warn(`Fly API transient failure on ${method} ${path} — retrying`, {
@@ -1372,8 +1531,15 @@ its own 6PN: under a policy allowing only `udp:53`, `molecule-pg-tenant.flycast`
1372
1531
  and `molecule-api.flycast` both RESOLVED and every TCP connect to them was
1373
1532
  dropped; re-applying the policy with `tcp:443` opened 443 and nothing else.
1374
1533
  Fly's sentence is about INGRESS — a Machine's egress TOWARD a Flycast address
1534
+ is filtered like any other. Drop a declared service's port and every database
1535
+ connection in the fleet goes with it.
1375
1536
 
1376
- _(description truncated — read the full text in `node_modules/@molecule/api-code-sandbox-flyio/dist/flycast.d.ts`.)_
1537
+ The cost of each derived port, stated plainly because a Fly policy has no
1538
+ destination field of any kind (also measured — see the `egress.ts` module
1539
+ description): the port is opened to EVERY host on the internet, not only to
1540
+ the private service it was derived for. That residual cannot be closed at this
1541
+ layer; it is named in `verifyEgress()`'s verdict and in
1542
+ `docs/sandbox-egress-enforcement.md`.
1377
1543
 
1378
1544
  ```typescript
1379
1545
  function mergeEgressPorts(
@@ -1465,24 +1631,30 @@ function parseSandboxId(id: string): { app: string; machineId: string }
1465
1631
 
1466
1632
  **Returns:** The app name and Machine id.
1467
1633
 
1468
- #### `appNameForProject(prefix, projectId)`
1634
+ #### `appNameNamespace(prefix)`
1635
+
1636
+ The one namespace mapping shared by app naming and fleet discovery.
1469
1637
 
1470
- Derives a Fly app name for a project.
1638
+ ```typescript
1639
+ function appNameNamespace(prefix: string): {
1640
+ prefix: string
1641
+ hashedPrefix: string
1642
+ searchPrefixes: string[]
1643
+ }
1644
+ ```
1471
1645
 
1472
- Fly app names are globally unique DNS labels: lowercase alphanumerics and
1473
- hyphens, no leading/trailing hyphen, at most 63 characters (they become
1474
- `<app>.fly.dev`). Project ids are uuids, so `<prefix>-<uuid>` fits with room
1475
- to spare; longer ids are truncated rather than rejected, and the trailing
1476
- hyphen that truncation can leave is stripped.
1646
+ #### `appNameForProject(prefix, projectId)`
1647
+
1648
+ Derive the collision-free app name for a complete prefix/project identity.
1477
1649
 
1478
1650
  ```typescript
1479
1651
  function appNameForProject(prefix: string, projectId: string): string
1480
1652
  ```
1481
1653
 
1482
1654
  - `prefix` — Configured app-name prefix.
1483
- - `projectId` — The project id from `SandboxConfig`.
1655
+ - `projectId` — Complete project or deploy identity.
1484
1656
 
1485
- **Returns:** A syntactically valid Fly app name.
1657
+ **Returns:** A DNS-safe app name no longer than 63 characters.
1486
1658
 
1487
1659
  #### `mapMachineState(state)`
1488
1660
 
@@ -1588,7 +1760,7 @@ function createProvider(
1588
1760
  config?: FlyioConfig,
1589
1761
  client?: FlyApiClient,
1590
1762
  store?: ObjectStore | null,
1591
- ): SandboxProvider
1763
+ ): SandboxProvider & FlyCreateAttemptProvider
1592
1764
  ```
1593
1765
 
1594
1766
  - `config` — Optional Fly configuration: API token/URL, org slug, app naming and isolation mode, region, base image, guest sizing, preview service, and the preview URL template. Every field has an env fallback.
@@ -2252,6 +2424,21 @@ refused, a leading `/` stripped, a write through a symlink refused, and
2252
2424
  setuid/setgid dropped by `--no-same-permissions` — were verified by running
2253
2425
  GNU tar 1.35 against crafted archives rather than inferred from its manual.
2254
2426
 
2427
+ **Lifecycle ownership.** Create NEVER deletes a Machine, volume, app or
2428
+ address. Configure `createAttemptJournal` to record every intended resource
2429
+ before its Fly write. Workspace ownership is durable immediately: an
2430
+ ambiguous create retains it for a later create or an explicit sweep, never
2431
+ infers deletion from a missing listing or an uncertain dispatch. The
2432
+ reconciler settles only retired attempt generations and skips serving
2433
+ Machines, attached volumes and bound workspaces. The one automatic exception
2434
+ is a durably recorded, definitive Machine-create rejection: under the same
2435
+ lifecycle lock, a fresh listing must show no Machine reference (and no
2436
+ Machines or untracked volumes in a per-project app) before the fenced sweep
2437
+ deletes the failed attempt's volume. The empty app remains KEEP until
2438
+ deliberate retirement through `destroyApp()`. Ambiguous outcomes and
2439
+ referenced resources stay KEPT and are reported. Deployed callers must
2440
+ serialize lifecycle operations per app through their lifecycle-lock adapter.
2441
+
2255
2442
  **Absence vs failure: `get()` only answers `null` for a REAL absence.**
2256
2443
  `get()` (and each read shaped like it — `list()`, `find()`, `getTemplate()`)
2257
2444
  reserves `null`/`[]` for a positive not-found answer — Fly answered 404 for
@@ -2395,10 +2582,19 @@ deliberately NOT implemented: they receive only a name, while a Fly volume
2395
2582
  needs an app, a region and a size, and a volume created in the wrong app can
2396
2583
  never be mounted. So passing `SandboxConfig.volumeName` provisions the volume
2397
2584
  inside the project's app, mounted at `/workspace`; `resources.diskMB` is
2398
- rounded UP to whole GB. Fly documents that "a volume can be attached to only
2399
- one Machine", which matches one sandbox per project. Fly does not document the
2400
- legal character set for volume names, so names are conservatively reduced to
2401
- `[A-Za-z0-9_]` — an assumption, flagged as such.
2585
+ rounded UP to whole GB. Creation sends the Machine's exact guest as Fly's
2586
+ `compute` placement hint, so Fly can choose a volume host that can run that
2587
+ Machine. If Machine creation still answers `412 volume_placement_capacity`,
2588
+ the provider preserves the volume and propagates the error for the caller's
2589
+ bounded retry, which adopts it by its durable Fly ID. Create itself never
2590
+ deletes its app or volume; only explicit retirement or the reference-checked
2591
+ definitive-failure sweep above can do so. Volume creates retain transport and
2592
+ 429 backoff. After an ambiguous failure, bounded lookups by name adopt a
2593
+ volume whose response was lost before any POST replay, preventing duplicates.
2594
+ Fly documents that "a volume can be attached to only one Machine", which
2595
+ matches one sandbox per project. Fly does
2596
+ not document the legal character set for volume names, so names are
2597
+ conservatively reduced to `[A-Za-z0-9_]` — an assumption, flagged as such.
2402
2598
 
2403
2599
  **Warm start: templates are tar archives in object storage.** Fly cannot
2404
2600
  `commit` a running Machine — it PULLS images from a registry — and the