@volter/twin-fly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,383 @@
1
+ // fly MACHINE LIFECYCLE + the CONTAINER-RUNTIME SEAM — the novel core of this pack.
2
+ //
3
+ // Fly.io's defining product idea is "a REST call gives you a running VM in seconds". This twin
4
+ // splits that into two planes, per the repo's real-plane doctrine (supabase: twin the control
5
+ // plane, run the real engine):
6
+ //
7
+ // CONTROL PLANE (this file + fly-twin.ts): apps/machines/volumes/secrets as kernel-backed
8
+ // rows; the machine lifecycle state machine (created → starting → started → stopping → stopped
9
+ // → destroying → destroyed, plus suspended) with a ledgered MachineEvent stream — all fully
10
+ // verifiable OFFLINE and deterministically.
11
+ //
12
+ // EXECUTION PLANE (the `FlyContainerRuntime` seam): when a machine is created/started the twin
13
+ // ACTUALLY runs `config.image` as a real local Docker container (fly-docker.ts implements the
14
+ // seam over the `docker` CLI): config.env → container env (plus Fly's documented FLY_* runtime
15
+ // environment), services/internal_port → published loopback ports, config.mounts → named
16
+ // docker volumes, stop → docker stop, start → RECREATE the container from the image (Fly
17
+ // resets a stopped Machine's rootfs), destroy → docker rm. The seam is INJECTED: verifies and
18
+ // unit tests inject a fake (or use the VIRTUAL runtime below), so the whole gate stays green on
19
+ // a machine with no Docker daemon; the real-Docker proof lives in
20
+ // fly-docker.integration.test.ts, which self-skips LOUDLY when `docker info` fails and is
21
+ // never counted as a capability proof.
22
+ //
23
+ // STATE-TRANSITION HONESTY (disclosed, README ## Coverage): a local `docker run`/`stop` completes
24
+ // synchronously, so the transient `starting`/`stopping`/`destroying` states are modeled but never
25
+ // observable — each API call lands on its terminal state (started/stopped/destroyed) before it
26
+ // returns, with the transition recorded in the machine's `events` array. The `wait` endpoint is
27
+ // therefore an immediate state check: already-there → 200 {ok:true}, otherwise Fly's own 408
28
+ // timeout — nothing in this twin changes state between two requests except the runtime-exit fold
29
+ // below.
30
+ //
31
+ // RUNTIME-EXIT FOLD: on every machine read the handler asks the runtime (when it can,
32
+ // `inspect()`) whether the container is still running; a container that exited folds the machine
33
+ // to `stopped` with an `exit` event — the genuine execution plane reflecting back into the
34
+ // twinned control plane. Proven offline with a fake runtime whose inspect() reports not-running.
35
+ //
36
+ // GROUNDING: the Machines API surface, request/response schemas, states and event shape are from
37
+ // Fly's first-party OpenAPI (live-fetched from docs.machines.dev/swagger/doc.json, OpenAPI 3.0.1
38
+ // "Machines API 1.0") cross-checked against superfly/fly-go (flyctl's own client — machine_types.go,
39
+ // flaps/flaps_machines.go). Where the two disagree the Go client wins (it is what flyctl actually
40
+ // speaks): e.g. lease responses are WRAPPED `{status:"success", data:{...}}` (fly-go MachineLease),
41
+ // not the OpenAPI's bare Lease schema. See spec-sources.json for the full citation + every
42
+ // ⚠ doc-unverified modeling choice.
43
+ import { applyTwinWrite, projectResources } from '@volter/world-core';
44
+ import { createHash } from 'node:crypto';
45
+
46
+ export const SERVICE = 'fly';
47
+
48
+ // ── the container-runtime seam ────────────────────────────────────────────────────────────────
49
+
50
+ /** One port the execution plane should expose on loopback. `internal` is the service's
51
+ * internal_port from the machine config; the runtime picks (and reports) the host port. */
52
+ export type FlyPortSpec = { internal: number };
53
+
54
+ /** What the control plane asks the execution plane to run. Derived from fly.MachineConfig. */
55
+ export type FlyContainerSpec = {
56
+ machineId: string;
57
+ appName: string;
58
+ machineName: string;
59
+ /** The docker image to run (fly.MachineConfig.image). */
60
+ image: string;
61
+ /** Guest limits from fly.MachineConfig.guest. A real execution runtime MUST enforce them. */
62
+ memoryMiB: number;
63
+ cpus: number;
64
+ /** config.env merged UNDER Fly's documented FLY_* runtime environment (flyRuntimeEnv). */
65
+ env: Record<string, string>;
66
+ /** init.cmd / init.entrypoint (fly.MachineInit). */
67
+ cmd?: string[];
68
+ entrypoint?: string[];
69
+ /** Every service's internal_port, deduped. */
70
+ ports: FlyPortSpec[];
71
+ /** config.mounts resolved to volume ids: mount a named local volume at `path`. */
72
+ mounts: Array<{ volumeId: string; path: string }>;
73
+ };
74
+
75
+ export type FlyExecResult = { exitCode: number; stdout: string; stderr: string };
76
+
77
+ /**
78
+ * The execution plane, injected. `fly-docker.ts` is the real one (docker CLI over the local
79
+ * daemon); `VIRTUAL_FLY_RUNTIME` below is the offline default (pure ledger, nothing runs);
80
+ * tests inject recording/failing fakes. Optional members are capabilities a runtime may lack —
81
+ * the handler degrades honestly (exec without a runtime that can exec → Fly-shaped 400, never a
82
+ * fabricated success).
83
+ */
84
+ export interface FlyContainerRuntime {
85
+ /** 'virtual' | 'docker' | a test fake's own tag. Recorded on the machine row (twin-only). */
86
+ readonly kind: string;
87
+ /** Launch the container. Returns the runtime's handle + any published loopback ports. */
88
+ run(spec: FlyContainerSpec): Promise<{ containerRef: string; publishedPorts?: Array<{ internal: number; host: number }> }>;
89
+ /** Stop the container (SIGTERM by default; `signal`/`timeoutSeconds` from Fly's StopRequest). */
90
+ stop(containerRef: string, opts?: { signal?: string; timeoutSeconds?: number }): Promise<void>;
91
+ /** Start a STOPPED container. Fly RESETS a stopped Machine's root filesystem on start —
92
+ * "Stopped Machines that are restarted are completely reset to their original state so that
93
+ * they start clean on the next run" (fly.io/docs/machines/api/machines-resource) — so a
94
+ * faithful execution plane RECREATES the container from `spec` instead of resuming the old
95
+ * writable layer; only mounted volumes survive. It therefore returns a NEW handle and NEW
96
+ * published ports, exactly like `run`. Absent → the handler removes and re-runs the spec,
97
+ * which is the same semantics by another route. */
98
+ start?(containerRef: string, spec: FlyContainerSpec): Promise<{ containerRef: string; publishedPorts?: Array<{ internal: number; host: number }> }>;
99
+ /** Remove the container (docker rm -f). */
100
+ remove(containerRef: string): Promise<void>;
101
+ /** Suspend/resume (docker pause/unpause). Optional; absent → suspend degrades to stop. */
102
+ pause?(containerRef: string): Promise<void>;
103
+ unpause?(containerRef: string): Promise<void>;
104
+ /** Deliver a signal without a state transition (docker kill --signal). */
105
+ signal?(containerRef: string, signal: string): Promise<void>;
106
+ /** Run a command inside the machine (docker exec). Absent → Fly-shaped 400 (see fly-twin.ts). */
107
+ exec?(containerRef: string, cmd: string[], opts?: { timeoutSeconds?: number }): Promise<FlyExecResult>;
108
+ /** Is the container still running? Absent → the control-plane state is taken as-is. */
109
+ inspect?(containerRef: string): Promise<{ running: boolean; exitCode?: number; oomKilled?: boolean }>;
110
+ /** Materialize/destroy a named local volume (docker volume create/rm). Optional. */
111
+ createVolume?(volumeId: string): Promise<void>;
112
+ removeVolume?(volumeId: string): Promise<void>;
113
+ /** Reclaim every backing resource owned by this runtime instance. */
114
+ cleanup?(): Promise<void>;
115
+ }
116
+
117
+ /**
118
+ * The offline default: a pure-ledger execution plane. Nothing runs, nothing listens; every
119
+ * lifecycle call succeeds and `inspect` reports the container as still running (so control-plane
120
+ * state is authoritative). This is what makes the ENTIRE control plane — states, events, wait,
121
+ * leases — verifiable with no Docker daemon anywhere near the gate.
122
+ */
123
+ export const VIRTUAL_FLY_RUNTIME: FlyContainerRuntime = {
124
+ kind: 'virtual',
125
+ async run(spec) {
126
+ return { containerRef: `virtual:${spec.machineId}` };
127
+ },
128
+ async stop() {},
129
+ // Nothing runs, so nothing has a filesystem to reset — the virtual plane models no state that
130
+ // could survive a stop→start, and hands back the same stable ledger handle.
131
+ async start(_containerRef: string, spec: FlyContainerSpec) {
132
+ return { containerRef: `virtual:${spec.machineId}` };
133
+ },
134
+ async remove() {},
135
+ async pause() {},
136
+ async unpause() {},
137
+ async signal() {},
138
+ async inspect() {
139
+ return { running: true };
140
+ },
141
+ };
142
+
143
+ // ── Fly's documented machine runtime environment (fly.io/docs/machines/runtime-environment):
144
+ // the env vars every real Fly machine boots with. The execution plane injects these UNDER the
145
+ // user's config.env (user env wins on collision, matching a user's ability to override). ────────
146
+ export function flyRuntimeEnv(m: { id: string; app_name: string; region: string; instance_id: string; image: string; memory_mb: number; private_ip: string }): Record<string, string> {
147
+ return {
148
+ FLY_APP_NAME: m.app_name,
149
+ FLY_MACHINE_ID: m.id,
150
+ FLY_ALLOC_ID: m.id,
151
+ FLY_REGION: m.region,
152
+ FLY_IMAGE_REF: m.image,
153
+ FLY_MACHINE_VERSION: m.instance_id,
154
+ FLY_PRIVATE_IP: m.private_ip,
155
+ FLY_PROCESS_GROUP: 'app',
156
+ FLY_VM_MEMORY_MB: String(m.memory_mb),
157
+ PRIMARY_REGION: m.region,
158
+ };
159
+ }
160
+
161
+ // ── machine states (fly-go machine_types.go MachineState* constants; the transient trio +
162
+ // `suspending`/`replacing`/`failed` appear in Fly's states documentation) ─────────────────────
163
+ export const FLY_MACHINE_STATES = [
164
+ 'created', 'starting', 'started', 'stopping', 'stopped',
165
+ 'suspending', 'suspended', 'destroying', 'destroyed', 'replacing', 'failed',
166
+ ] as const;
167
+ export type FlyMachineState = typeof FLY_MACHINE_STATES[number];
168
+
169
+ // ── kernel-backed rows (kernel-backed projection rows) ─────────────────────────────
170
+ // Kernel META reserves `type`/`id`/`updatedAt` inside `fields` — every field below is snake_case
171
+ // (created_at, instance_id, image_ref, …) so nothing collides; the bare vendor id is re-attached
172
+ // AFTER projection, never written inside `fields`.
173
+
174
+ export function nowIso(occurredAt?: string): string {
175
+ return occurredAt ?? new Date().toISOString();
176
+ }
177
+ export function nowEpochMs(occurredAt?: string): number {
178
+ return Date.parse(nowIso(occurredAt));
179
+ }
180
+
181
+ /** The live rows of a type (protocol 2: a subject's id is the vendor's — an app's name, a machine's id, a volume's id —
182
+ * with no type prefix; a delete is the kernel's tombstone `deleted: true`, cleared by the subject's next write). */
183
+ export function rows(type: string, root?: string): Array<Record<string, unknown>> {
184
+ return projectResources(SERVICE, root)
185
+ .filter((r) => r.type === type && (r as Record<string, unknown>).deleted !== true)
186
+ .map((r) => ({ ...r }));
187
+ }
188
+ /** Every row of a type the tree has ever held, tombstoned ones included — the ordinal an id seed takes. */
189
+ export function countRows(type: string, root?: string): number {
190
+ return projectResources(SERVICE, root).filter((r) => r.type === type).length;
191
+ }
192
+ export function getRow(type: string, id: string, root?: string): Record<string, unknown> | undefined {
193
+ return rows(type, root).find((r) => r.id === id);
194
+ }
195
+ function view(r: Record<string, unknown>): Record<string, unknown> {
196
+ // the row as the tree holds it, its own bookkeeping (`_`-prefixed) included — the wire views pick their fields
197
+ const { type: _t, updatedAt: _u, ...rest } = r;
198
+ return rest;
199
+ }
200
+
201
+ /**
202
+ * The one kernel write choke point. Every write folds a per-subject `_rev` ordinal into `fields`
203
+ * (read-modify-write off the current projection): the kernel dedupes actions by content +
204
+ * millisecond timestamp, so without `rev` a machine returning to a PREVIOUS value under a pinned
205
+ * clock (start→stop→start in one verify) would silently land as `replayed` — reply and stored
206
+ * state disagreeing (the upstash lesson, ADDING_A_TWIN §5).
207
+ */
208
+ export async function write(
209
+ type: string,
210
+ id: string,
211
+ fields: Record<string, unknown>,
212
+ op: string,
213
+ root: string | undefined,
214
+ occurredAt: string | undefined,
215
+ ): Promise<Record<string, unknown>> {
216
+ const current = getRow(type, id, root);
217
+ const rev = typeof current?._rev === 'number' ? (current._rev as number) + 1 : 1;
218
+ const { resource } = await applyTwinWrite(
219
+ SERVICE,
220
+ { operation: op, subjectType: type, subjectId: id, fields: { ...fields, _rev: rev }, ...(occurredAt ? { occurredAt } : {}), actor: { kind: 'agent' } },
221
+ root,
222
+ );
223
+ return view({ ...resource, id });
224
+ }
225
+
226
+ // ── id minting — entropy-based, NEVER a row count (the count-mint collision class three §9
227
+ // reviews found independently; ADDING_A_TWIN §5). Shapes are vendor-faithful:
228
+ // machine id: 14 lowercase hex chars (e.g. `d8d1e2ea044148` — flyctl's own shape)
229
+ // instance id: 26-char Crockford-base32 ULID-ish (`01JGXW…` — "unique for each version")
230
+ // volume id: `vol_` + 20 lowercase base32 (fly-go VolumeIDPrefix-compatible)
231
+ // lease nonce: 24 hex chars
232
+ // ────────────────────────────────────────────────────────────────────────────────────────────
233
+ /**
234
+ * The pack's id byte source — SEEDED, never random (R9).
235
+ *
236
+ * Real Fly mints machine ids, instance ids, volume ids and lease nonces from entropy, and this
237
+ * file used to do the same (`randomUUID()`), which made every one of them differ between two
238
+ * identical worlds the moment a caller listed machines or volumes. They are now a digest of the
239
+ * SEED the caller derives from the state the write is about to land on: the world instant plus
240
+ * the ordinal the new row takes among the rows of its type.
241
+ *
242
+ * That ordinal is the reason this does not resurrect the §9 round-one finding it replaced (a
243
+ * NAME hash, which made a deleted-and-recreated app inherit its predecessor's internal id).
244
+ * A destroyed machine or volume stays a row (`_purged`, state `destroyed`) and a deleted app is a tombstone the tree
245
+ * keeps, so `countRows(type)` only ever grows: recreating a destroyed name mints at a higher ordinal, hence a fresh id.
246
+ */
247
+ function entropyHex(len: number, seed: string): string {
248
+ let out = '';
249
+ for (let round = 0; out.length < len; round += 1) out += createHash('sha256').update(`${round}:${seed}`).digest('hex');
250
+ return out.slice(0, len);
251
+ }
252
+ const B32 = '0123456789abcdefghjkmnpqrstvwxyz';
253
+ function entropyB32(len: number, seed: string): string {
254
+ const hex = entropyHex(len * 2, seed);
255
+ let out = '';
256
+ for (let i = 0; i < len; i++) out += B32[parseInt(hex.slice(i * 2, i * 2 + 2), 16) % 32];
257
+ return out;
258
+ }
259
+ export function newMachineId(seed: string): string {
260
+ return entropyHex(14, seed);
261
+ }
262
+ export function newInstanceId(occurredAt: string | undefined, seed: string): string {
263
+ // ULID-like: time prefix (10 chars) + seeded tail (16 chars), uppercase Crockford base32.
264
+ const t = nowEpochMs(occurredAt);
265
+ const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
266
+ let time = '';
267
+ let ms = t;
268
+ for (let i = 0; i < 10; i++) { time = A[ms % 32] + time; ms = Math.floor(ms / 32); }
269
+ return time + entropyB32(16, seed).toUpperCase();
270
+ }
271
+ export function newVolumeId(seed: string): string {
272
+ return `vol_${entropyB32(20, seed)}`;
273
+ }
274
+ export function newLeaseNonce(seed: string): string {
275
+ return entropyHex(24, seed);
276
+ }
277
+ /** An app's internal numeric id — the same seeded source, read as a 24-bit integer. */
278
+ export function newAppNumericId(seed: string): number {
279
+ return parseInt(entropyHex(6, seed), 16);
280
+ }
281
+ /** Fly machine names default to adjective-noun-number; the twin's default is the same shape. */
282
+ const NAME_ADJ = ['ancient', 'bold', 'calm', 'divine', 'empty', 'frosty', 'green', 'hidden', 'icy', 'jolly', 'late', 'misty', 'nameless', 'odd', 'purple', 'quiet', 'rough', 'shy', 'twilight', 'young'];
283
+ const NAME_NOUN = ['breeze', 'cherry', 'dawn', 'feather', 'glitter', 'haze', 'leaf', 'meadow', 'night', 'paper', 'rain', 'shadow', 'silence', 'smoke', 'star', 'sun', 'thunder', 'violet', 'water', 'wind'];
284
+ export function newMachineName(seed: string): string {
285
+ const h = entropyHex(8, seed);
286
+ const a = NAME_ADJ[parseInt(h.slice(0, 2), 16) % NAME_ADJ.length];
287
+ const n = NAME_NOUN[parseInt(h.slice(2, 4), 16) % NAME_NOUN.length];
288
+ return `${a}-${n}-${(parseInt(h.slice(4, 8), 16) % 9000) + 1000}`;
289
+ }
290
+ /** 6PN address: deterministic per machine id (fdaa:… shape, fly-go's "internal 6PN address").
291
+ * Ledgered only — nothing routes it locally; published loopback ports are the local
292
+ * reachability story. */
293
+ export function machinePrivateIp(machineId: string): string {
294
+ const h = createHash('sha256').update(machineId).digest('hex');
295
+ return `fdaa:0:${h.slice(0, 4)}:a7b:${h.slice(4, 8)}:${h.slice(8, 12)}:${h.slice(12, 16)}:2`;
296
+ }
297
+
298
+ // ── MachineEvent stream (fly-go MachineEvent: {id?, type, status, source, timestamp}) ─────────
299
+ export type FlyMachineEvent = {
300
+ id: string;
301
+ type: string;
302
+ status: string;
303
+ source: 'flyd' | 'user';
304
+ timestamp: number;
305
+ /** fly-go MachineEvent.request — used for the exit payload ({exit_event:{exit_code}}), which is
306
+ * what flyctl reads to say WHY a machine died. Populated by the runtime-exit fold. */
307
+ request?: Record<string, unknown>;
308
+ };
309
+ export function machineEvent(type: string, status: string, source: 'flyd' | 'user', occurredAt: string | undefined, seq: number, request?: Record<string, unknown>): FlyMachineEvent {
310
+ // Event id: entropy-free WITHIN a machine write (derived from type+status+seq+time) so a
311
+ // pinned-clock verify sees stable events, but unique across the stream via seq.
312
+ const t = nowEpochMs(occurredAt);
313
+ const id = createHash('sha256').update(`${type}:${status}:${seq}:${t}`).digest('hex').slice(0, 26);
314
+ return { id, type, status, source, timestamp: t, ...(request ? { request } : {}) };
315
+ }
316
+
317
+ // ── image_ref (fly-go ImageRef {registry, repository, tag, digest, labels}) ───────────────────
318
+ export function imageRefOf(image: string): Record<string, unknown> {
319
+ // "registry/repository:tag" | "repository:tag" | bare repository. Digest is the local twin's
320
+ // deterministic stand-in (sha256 of the image string) — a real registry digest needs a real
321
+ // pull, which only the docker execution plane does; the ledgered digest is disclosed as
322
+ // twin-derived (spec-sources.json).
323
+ let rest = image;
324
+ let tag = 'latest';
325
+ const at = rest.indexOf('@');
326
+ let digest = '';
327
+ if (at !== -1) { digest = rest.slice(at + 1); rest = rest.slice(0, at); }
328
+ const colon = rest.lastIndexOf(':');
329
+ if (colon !== -1 && !rest.slice(colon + 1).includes('/')) { tag = rest.slice(colon + 1); rest = rest.slice(0, colon); }
330
+ const firstSeg = rest.split('/')[0] ?? '';
331
+ const hasRegistry = firstSeg.includes('.') || firstSeg.includes(':') || firstSeg === 'localhost';
332
+ const registry = hasRegistry ? firstSeg : 'registry-1.docker.io';
333
+ const repository = hasRegistry ? rest.split('/').slice(1).join('/') : (rest.includes('/') ? rest : `library/${rest}`);
334
+ return {
335
+ registry,
336
+ repository,
337
+ tag,
338
+ digest: digest || `sha256:${createHash('sha256').update(image).digest('hex')}`,
339
+ labels: {},
340
+ };
341
+ }
342
+
343
+ // ── container-spec derivation from a machine row ──────────────────────────────────────────────
344
+ export function containerSpecOf(m: Record<string, unknown>): FlyContainerSpec {
345
+ const config = (m.config ?? {}) as Record<string, any>;
346
+ const image = String(config.image ?? '');
347
+ const memoryMb = Number(config.guest?.memory_mb ?? 256);
348
+ const cpus = Number(config.guest?.cpus ?? 1);
349
+ const base = flyRuntimeEnv({
350
+ id: String(m.id),
351
+ app_name: String(m.app_name),
352
+ region: String(m.region),
353
+ instance_id: String(m.instance_id),
354
+ image,
355
+ memory_mb: memoryMb,
356
+ private_ip: String(m.private_ip),
357
+ });
358
+ const env: Record<string, string> = { ...base };
359
+ for (const [k, v] of Object.entries((config.env ?? {}) as Record<string, unknown>)) env[k] = String(v);
360
+ const ports: FlyPortSpec[] = [];
361
+ for (const svc of (config.services ?? []) as Array<Record<string, any>>) {
362
+ const p = Number(svc.internal_port ?? 0);
363
+ if (p > 0 && !ports.some((x) => x.internal === p)) ports.push({ internal: p });
364
+ }
365
+ const mounts: Array<{ volumeId: string; path: string }> = [];
366
+ for (const mt of (config.mounts ?? []) as Array<Record<string, any>>) {
367
+ if (mt.volume && mt.path) mounts.push({ volumeId: String(mt.volume), path: String(mt.path) });
368
+ }
369
+ const init = (config.init ?? {}) as Record<string, any>;
370
+ return {
371
+ machineId: String(m.id),
372
+ appName: String(m.app_name),
373
+ machineName: String(m.name),
374
+ image,
375
+ memoryMiB: Number.isFinite(memoryMb) && memoryMb > 0 ? Math.floor(memoryMb) : 256,
376
+ cpus: Number.isFinite(cpus) && cpus > 0 ? cpus : 1,
377
+ env,
378
+ ...(Array.isArray(init.cmd) && init.cmd.length > 0 ? { cmd: init.cmd.map(String) } : {}),
379
+ ...(Array.isArray(init.entrypoint) && init.entrypoint.length > 0 ? { entrypoint: init.entrypoint.map(String) } : {}),
380
+ ports,
381
+ mounts,
382
+ };
383
+ }
@@ -0,0 +1,38 @@
1
+ // The execution-plane CHOICE, resolved at the host boundary (cli.ts, tests) — dev plane, never
2
+ // imported by the serve path. 'virtual' is the pure ledger (offline default, nothing runs);
3
+ // 'docker' is fly-docker.ts (machines genuinely run as local containers), a lazy import so the
4
+ // docker module enters no graph unless asked for; a FlyContainerRuntime object injects a custom
5
+ // plane. Absent capability FAILS with the fix, never a silent downgrade to virtual.
6
+ import { VIRTUAL_FLY_RUNTIME, type FlyContainerRuntime } from './fly-machines.ts';
7
+
8
+ export type FlyServerRuntimeChoice = 'virtual' | 'docker' | FlyContainerRuntime;
9
+
10
+ /** Configuration of the LOCAL EXECUTION plane, declared here at the host boundary so a caller can
11
+ * name it without importing the docker module (structurally the docker runtime's own options).
12
+ * Ignored by the virtual plane, which runs nothing and therefore admits nothing. */
13
+ export interface FlyLocalExecutionOptions {
14
+ /** MiB of writable storage admission keeps FREE beyond a machine's own need. Mechanism, not
15
+ * policy: a World on a small disk configures the reserve down rather than the runtime quietly
16
+ * lowering its floor. Omitted => the runtime's 2048 MiB default. */
17
+ storageReserveMiB?: number;
18
+ }
19
+
20
+ export async function resolveFlyRuntime(
21
+ choice: FlyServerRuntimeChoice | undefined,
22
+ root = process.cwd(),
23
+ options: FlyLocalExecutionOptions = {},
24
+ ): Promise<FlyContainerRuntime> {
25
+ if (choice === undefined || choice === 'virtual') return VIRTUAL_FLY_RUNTIME;
26
+ if (choice === 'docker') {
27
+ // Lazy import so the docker module never enters the graph unless asked for.
28
+ const { FlyDockerRuntime, dockerAvailable } = await import('./fly-docker.ts');
29
+ if (!dockerAvailable()) {
30
+ // Absent capability => FAIL with the fix, never a silent downgrade to virtual (a caller who
31
+ // asked for real machines must not think they got them).
32
+ throw new Error('machine provider: the configured local execution runtime is unavailable');
33
+ }
34
+ return new FlyDockerRuntime(root, options);
35
+ }
36
+ return choice;
37
+ }
38
+
@@ -0,0 +1,116 @@
1
+ // fly twin HTTP server — serve the Machines API twin handler over plain HTTP so any unmodified
2
+ // Machines API client (flyctl's flaps transport, fly-admin, plain fetch — Fly ships no canonical
3
+ // npm SDK) works against it by pointing FLY_API_HOSTNAME at http://127.0.0.1:<port>, exactly the
4
+ // env var Fly's own docs use for the base URL. Writable by default; pass readOnly to reject
5
+ // writes with 405. State is the kernel projection (no side-store) — see fly-twin.ts.
6
+ //
7
+ // The EXECUTION PLANE arrives INJECTED and pre-resolved (a FlyContainerRuntime): the pure-ledger
8
+ // virtual runtime is the default — nothing runs — and the real docker plane is a dev-plane
9
+ // adapter the HOST BOUNDARY resolves (cli.ts via fly-runtime-choice.ts). This module never names
10
+ // fly-docker.ts, so the serve closure carries no process, cwd or child_process (R4, R12b): the
11
+ // engine is a provider behind a door, never the serve path's business.
12
+ import { serveHttp } from '@volter/world-core';
13
+ import { rows, VIRTUAL_FLY_RUNTIME, type FlyContainerRuntime } from './fly-machines.ts';
14
+ import { worldNow, statefulTwinManifest} from '@volter/world-core';
15
+ import { handleFlyTwinRequest } from './fly-twin.ts';
16
+
17
+ /** Options every Fly-twin HTTP surface needs, independent of who owns the socket. The
18
+ * execution plane arrives PRE-RESOLVED: `resolveFlyRuntime` is async (the docker plane is a
19
+ * lazy import), and a fetch factory must be synchronous to mount in-process. Omitting it
20
+ * selects the offline default — the pure ledger, nothing runs. */
21
+ export interface FlyTwinFetchOptions {
22
+ root?: string;
23
+ readOnly?: boolean;
24
+ runtime?: FlyContainerRuntime;
25
+ }
26
+
27
+ /**
28
+ * The pack's whole HTTP surface as a plain fetch (runtime contract R12b): `createFlyTwinServer`
29
+ * is `Bun.serve` around this closure, and a Worker/DO entry mounts the same closure in-process,
30
+ * so the standalone and hosted lanes execute identical serving bytes.
31
+ *
32
+ * Hand-rolled rather than the kernel adapter (`createTwinFetchFromHandler`): the handler takes
33
+ * this pack's `runtime` (the execution plane) as a per-instance argument and lower-cases its
34
+ * header map, which the adapter's fixed request shape does not thread.
35
+ */
36
+ export function createFlyTwinFetch(options: FlyTwinFetchOptions = {}): (request: Request) => Promise<Response> {
37
+ const readOnly = options.readOnly ?? false;
38
+ const root = options.root; // undefined resolves through the kernel's projectRoot — the serve path never reads the process (R4)
39
+ const runtime = options.runtime ?? VIRTUAL_FLY_RUNTIME;
40
+ return async function flyTwinFetch(request: Request): Promise<Response> {
41
+ const url = new URL(request.url);
42
+ // GET /twin — the discovery manifest (education inside the twin). Constants only: no
43
+ // clock, no state, so every replay of this door is byte-identical (R9).
44
+ if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
45
+ return Response.json(statefulTwinManifest({ vendor: 'fly', twinOf: "Fly.io's Machines API (api.machines.dev/v1)", stores: 'apps, machines and volumes — with --docker each Machine runs as a real local container' }));
46
+ }
47
+ const body = request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : '';
48
+ const headers: Record<string, string> = {};
49
+ request.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; });
50
+ const { status, body: out } = await handleFlyTwinRequest({
51
+ method: request.method,
52
+ path: url.pathname + (url.search || ''),
53
+ body,
54
+ headers,
55
+ readOnly,
56
+ runtime,
57
+ occurredAt: worldNow(),
58
+ root,
59
+ });
60
+ if (status === 204 || out === null) return new Response(null, { status });
61
+ return new Response(JSON.stringify(out), { status, headers: { 'content-type': 'application/json' } });
62
+ };
63
+ }
64
+
65
+ export async function createFlyTwinServer(
66
+ options: { cleanupOwnedOnStart?: boolean; root?: string; port?: number; readOnly?: boolean; runtime?: FlyContainerRuntime } = {},
67
+ ): Promise<{ port: number; runtimeKind: string; stop: () => Promise<void> }> {
68
+ const readOnly = options.readOnly ?? false;
69
+ const root = options.root; // undefined resolves through the kernel's projectRoot — the serve path never reads the process (R4)
70
+ const runtime = options.runtime ?? VIRTUAL_FLY_RUNTIME;
71
+ // A freshly claimed World instance has no retained provider ledger, but an
72
+ // abrupt prior runner termination may have prevented the old service's stop
73
+ // hook. Reclaim only resources bearing this exact World-service owner before
74
+ // accepting new demand. Standalone/persistent providers opt out by default.
75
+ if (options.cleanupOwnedOnStart) await runtime.cleanup?.();
76
+ const server = await serveHttp({
77
+ port: options.port ?? 0,
78
+ idleTimeout: 60,
79
+ fetch: createFlyTwinFetch({ root, readOnly, runtime }),
80
+ });
81
+ let stopPromise: Promise<void> | undefined;
82
+ const stop = (): Promise<void> => {
83
+ if (stopPromise) return stopPromise;
84
+ stopPromise = (async () => {
85
+ server.stop(true);
86
+
87
+ // A World owns the full lifecycle, including resources created behind a
88
+ // service boundary. Give every recorded Machine a graceful shutdown
89
+ // before removal, then let the runtime sweep any owner-labelled residue
90
+ // left by an interrupted API operation.
91
+ const failures: string[] = [];
92
+ const machines = rows('machine', root)
93
+ .map((machine) => machine._container_ref)
94
+ .filter((value): value is string => typeof value === 'string' && value.length > 0);
95
+ await Promise.all(machines.map(async (containerRef) => {
96
+ try { await runtime.stop(containerRef, { timeoutSeconds: 3 }); } catch { /* already stopped or gone */ }
97
+ try { await runtime.remove(containerRef); } catch (error) {
98
+ failures.push(error instanceof Error ? error.message : String(error));
99
+ }
100
+ }));
101
+
102
+ if (runtime.removeVolume) {
103
+ const volumes = rows('volume', root).map((volume) => String(volume.id));
104
+ await Promise.all(volumes.map(async (volumeId) => {
105
+ try { await runtime.removeVolume!(volumeId); } catch { /* never materialized or already gone */ }
106
+ }));
107
+ }
108
+ try { await runtime.cleanup?.(); } catch (error) {
109
+ failures.push(error instanceof Error ? error.message : String(error));
110
+ }
111
+ if (failures.length > 0) throw new Error(`world service cleanup failed: ${failures.join('; ')}`);
112
+ })();
113
+ return stopPromise;
114
+ };
115
+ return { port: server.port ?? options.port ?? 0, runtimeKind: runtime.kind, stop };
116
+ }