@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,8 @@
1
+ export type FlyConformanceReport = {
2
+ ok: boolean;
3
+ endpointsChecked: number;
4
+ endpointsProbed: number;
5
+ resourceTypesChecked: number;
6
+ violations: string[];
7
+ };
8
+ export declare function checkFlyConformance(): Promise<FlyConformanceReport>;
@@ -0,0 +1,142 @@
1
+ // fly conformance (dev-only; lazy-imported by the CLI, NEVER from index.ts/runtime — E2).
2
+ //
3
+ // The tinybird PROBES pattern (ADDING_A_TWIN §6 "conformance teeth"): ONE REAL REQUEST per
4
+ // claimed endpoint, each declaring the OUTCOME a live handler produces — an expected status set
5
+ // plus a predicate over the body — driven against a throwaway root, with the endpoint census a
6
+ // two-way bijection with `flyTwinSnapshot()`. A deleted handler branch falls through to the
7
+ // router's 404/405 or answers the wrong shape, and either way this goes red. Two constants
8
+ // asserting about each other (the tinybird round-one false-green) is exactly what this is not:
9
+ // every expectation below is a LITERAL, never an import from the handler.
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { handleFlyTwinRequest, flyTwinSnapshot } from "./fly-twin.js";
14
+ const isObject = (b) => !!b && typeof b === 'object' && !Array.isArray(b);
15
+ const hasKeys = (...keys) => (b) => isObject(b) && keys.every((k) => b[k] !== undefined);
16
+ // Fixture ids minted by seeding, addressed by the probes.
17
+ const APP = 'probe-app';
18
+ const fx = { machineId: '', stoppedMachineId: '', volumeId: '', doomedVolumeId: '', leaseNonce: '' };
19
+ /** A representative request per declared endpoint. DESTRUCTIVE probes target throwaway fixtures
20
+ * of their own; the shared fixture machine's probes are ORDER-DEPENDENT by design (probes run in
21
+ * declaration order) and each order dependency is stated inline where it is load-bearing. */
22
+ const PROBES = {
23
+ 'GET /v1/apps': { method: 'GET', path: '/v1/apps?org_slug=personal', status: [200], expect: (b) => isObject(b) && Array.isArray(b.apps) && typeof b.total_apps === 'number' },
24
+ 'POST /v1/apps': { method: 'POST', path: '/v1/apps', body: { app_name: 'probe-app-created', org_slug: 'personal' }, status: [201] },
25
+ 'GET /v1/apps/{app_name}': { method: 'GET', path: `/v1/apps/${APP}`, status: [200], expect: (b) => isObject(b) && b.name === APP && isObject(b.organization) },
26
+ 'DELETE /v1/apps/{app_name}': { method: 'DELETE', path: '/v1/apps/probe-app-doomed', status: [202] },
27
+ 'GET /v1/apps/{app_name}/machines': { method: 'GET', path: `/v1/apps/${APP}/machines`, status: [200], expect: (b) => Array.isArray(b) && b.some((m) => m.id === fx.machineId) },
28
+ 'POST /v1/apps/{app_name}/machines': { method: 'POST', path: `/v1/apps/${APP}/machines`, body: { config: { image: 'nginx:alpine' } }, status: [200], expect: (b) => isObject(b) && b.state === 'started' && typeof b.id === 'string' },
29
+ 'GET /v1/apps/{app_name}/machines/{machine_id}': { method: 'GET', path: () => `/v1/apps/${APP}/machines/${fx.machineId}`, status: [200], expect: (b) => isObject(b) && b.id === fx.machineId && Array.isArray(b.events) },
30
+ 'POST /v1/apps/{app_name}/machines/{machine_id}': { method: 'POST', path: () => `/v1/apps/${APP}/machines/${fx.machineId}`, body: { config: { image: 'nginx:1.27-alpine', metadata: { probe_key: 'probe-value' } } }, status: [200], expect: (b) => isObject(b) && isObject(b.config) && b.config.image === 'nginx:1.27-alpine' },
31
+ 'DELETE /v1/apps/{app_name}/machines/{machine_id}': { method: 'DELETE', path: () => `/v1/apps/${APP}/machines/${fx.stoppedMachineId}`, status: [200], expect: (b) => isObject(b) && b.ok === true },
32
+ // ORDER: stop first, so the start probe below exercises the REAL start path (previous_state
33
+ // "stopped"), not the idempotent already-started short-circuit.
34
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/stop': { method: 'POST', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/stop`, status: [200], expect: (b) => isObject(b) && b.ok === true },
35
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/start': { method: 'POST', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/start`, status: [200], expect: (b) => isObject(b) && b.previous_state === 'stopped' },
36
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/restart': { method: 'POST', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/restart`, status: [200], expect: (b) => isObject(b) && b.ok === true },
37
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/signal': { method: 'POST', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/signal`, body: { signal: 'SIGTERM' }, status: [200], expect: (b) => isObject(b) && b.ok === true },
38
+ // Probes run in declaration order, so exec probes while the machine is still STARTED (signal
39
+ // above leaves it started; suspend below takes it out of exec's reach). The default (virtual)
40
+ // execution plane cannot execute; the honest, documented outcome is the vendor-enveloped 400
41
+ // naming the virtual plane — never a fabricated {exit_code:0}.
42
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/exec': { method: 'POST', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/exec`, body: { command: ['echo', 'hi'] }, status: [400], expect: (b) => isObject(b) && typeof b.error === 'string' && b.error.includes('virtual') },
43
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/suspend': { method: 'POST', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/suspend`, status: [200], expect: (b) => isObject(b) && b.ok === true },
44
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/cordon': { method: 'POST', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/cordon`, status: [200], expect: (b) => isObject(b) && b.ok === true },
45
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/uncordon': { method: 'POST', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/uncordon`, status: [200], expect: (b) => isObject(b) && b.ok === true },
46
+ // After suspend the fixture machine is suspended, so wait?state=suspended is the honest probe
47
+ // of the wait mechanism itself (already-there → 200 {ok:true}).
48
+ 'GET /v1/apps/{app_name}/machines/{machine_id}/wait': { method: 'GET', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/wait?state=suspended`, status: [200], expect: (b) => isObject(b) && b.ok === true },
49
+ 'GET /v1/apps/{app_name}/machines/{machine_id}/events': { method: 'GET', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/events`, status: [200], expect: (b) => Array.isArray(b) && b.length > 0 && hasKeys('type', 'status', 'timestamp')(b[0]) },
50
+ 'GET /v1/apps/{app_name}/machines/{machine_id}/versions': { method: 'GET', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/versions`, status: [200], expect: (b) => Array.isArray(b) && b.length > 0 && hasKeys('version', 'user_config')(b[0]) },
51
+ 'GET /v1/apps/{app_name}/machines/{machine_id}/lease': { method: 'GET', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/lease`, status: [200], expect: (b) => isObject(b) && b.status === 'success' && isObject(b.data) && typeof b.data.nonce === 'string' },
52
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/lease': { method: 'POST', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/lease`, headers: () => ({ 'fly-machine-lease-nonce': fx.leaseNonce }), body: { ttl: 60 }, status: [201], expect: (b) => isObject(b) && b.status === 'success' },
53
+ 'DELETE /v1/apps/{app_name}/machines/{machine_id}/lease': { method: 'DELETE', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/lease`, headers: () => ({ 'fly-machine-lease-nonce': fx.leaseNonce }), status: [200], expect: (b) => isObject(b) && b.status === 'success' },
54
+ 'GET /v1/apps/{app_name}/machines/{machine_id}/metadata': { method: 'GET', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/metadata`, status: [200], expect: (b) => isObject(b) && b.probe_key === 'probe-value' },
55
+ 'GET /v1/apps/{app_name}/machines/{machine_id}/metadata/{key}': { method: 'GET', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/metadata/probe_key`, status: [200], expect: (b) => isObject(b) && b.value === 'probe-value' },
56
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/metadata/{key}': { method: 'POST', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/metadata/probe_key2`, body: { value: 'v2' }, status: [204] },
57
+ 'DELETE /v1/apps/{app_name}/machines/{machine_id}/metadata/{key}': { method: 'DELETE', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/metadata/probe_key2`, status: [204] },
58
+ 'GET /v1/apps/{app_name}/volumes': { method: 'GET', path: `/v1/apps/${APP}/volumes`, status: [200], expect: (b) => Array.isArray(b) && b.some((v) => v.id === fx.volumeId) },
59
+ 'POST /v1/apps/{app_name}/volumes': { method: 'POST', path: `/v1/apps/${APP}/volumes`, body: { name: 'probe_vol_created', size_gb: 1 }, status: [200], expect: (b) => isObject(b) && typeof b.id === 'string' && b.id.startsWith('vol_') },
60
+ 'GET /v1/apps/{app_name}/volumes/{volume_id}': { method: 'GET', path: () => `/v1/apps/${APP}/volumes/${fx.volumeId}`, status: [200], expect: (b) => isObject(b) && b.id === fx.volumeId && b.name === 'probe_vol' },
61
+ 'PUT /v1/apps/{app_name}/volumes/{volume_id}': { method: 'PUT', path: () => `/v1/apps/${APP}/volumes/${fx.volumeId}`, body: { snapshot_retention: 9 }, status: [200], expect: (b) => isObject(b) && b.snapshot_retention === 9 },
62
+ 'DELETE /v1/apps/{app_name}/volumes/{volume_id}': { method: 'DELETE', path: () => `/v1/apps/${APP}/volumes/${fx.doomedVolumeId}`, status: [200], expect: (b) => isObject(b) && b.state === 'pending_destroy' },
63
+ 'PUT /v1/apps/{app_name}/volumes/{volume_id}/extend': { method: 'PUT', path: () => `/v1/apps/${APP}/volumes/${fx.volumeId}/extend`, body: { size_gb: 4 }, status: [200], expect: (b) => isObject(b) && isObject(b.volume) && b.volume.size_gb === 4 && typeof b.needs_restart === 'boolean' },
64
+ // ORDER: POST first — the GET after it is what gives the (empty-bodied) create its teeth: a
65
+ // handler stubbed to `return {status:200, body:{}}` would leave the list empty and go red here.
66
+ 'POST /v1/apps/{app_name}/volumes/{volume_id}/snapshots': { method: 'POST', path: () => `/v1/apps/${APP}/volumes/${fx.volumeId}/snapshots`, status: [200] },
67
+ 'GET /v1/apps/{app_name}/volumes/{volume_id}/snapshots': { method: 'GET', path: () => `/v1/apps/${APP}/volumes/${fx.volumeId}/snapshots`, status: [200], expect: (b) => Array.isArray(b) && b.length === 1 && String(b[0].id).startsWith('vs_') },
68
+ 'GET /v1/apps/{app_name}/secrets': { method: 'GET', path: `/v1/apps/${APP}/secrets`, status: [200], expect: (b) => isObject(b) && Array.isArray(b.secrets) },
69
+ 'POST /v1/apps/{app_name}/secrets': { method: 'POST', path: `/v1/apps/${APP}/secrets`, body: { values: { PROBE_BULK: 'x' } }, status: [200], expect: (b) => isObject(b) && Array.isArray(b.secrets) && typeof b.version === 'number' },
70
+ 'GET /v1/apps/{app_name}/secrets/{secret_name}': { method: 'GET', path: `/v1/apps/${APP}/secrets/PROBE_SECRET`, status: [200], expect: (b) => isObject(b) && b.name === 'PROBE_SECRET' && b.value === undefined },
71
+ 'POST /v1/apps/{app_name}/secrets/{secret_name}': { method: 'POST', path: `/v1/apps/${APP}/secrets/PROBE_SECRET_CREATED`, body: { value: 's3cret' }, status: [201], expect: (b) => isObject(b) && b.name === 'PROBE_SECRET_CREATED' && typeof b.digest === 'string' },
72
+ 'DELETE /v1/apps/{app_name}/secrets/{secret_name}': { method: 'DELETE', path: `/v1/apps/${APP}/secrets/PROBE_SECRET_DOOMED`, status: [200], expect: (b) => isObject(b) && typeof b.version === 'number' },
73
+ };
74
+ export async function checkFlyConformance() {
75
+ const snapshot = flyTwinSnapshot();
76
+ const violations = [];
77
+ const root = mkdtempSync(join(tmpdir(), 'fly-conformance-'));
78
+ const at = '2026-02-01T00:00:00.000Z';
79
+ const call = (method, path, body, headers) => handleFlyTwinRequest({
80
+ method, path, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), root, occurredAt: at,
81
+ headers: { authorization: 'Bearer probe-token', ...(headers ?? {}) },
82
+ });
83
+ let probed = 0;
84
+ try {
85
+ // Seed the fixtures the probes address, through the twin's own write path.
86
+ await call('POST', '/v1/apps', { app_name: APP, org_slug: 'personal' });
87
+ await call('POST', '/v1/apps', { app_name: 'probe-app-doomed', org_slug: 'personal' });
88
+ const m = await call('POST', `/v1/apps/${APP}/machines`, { config: { image: 'nginx:alpine', metadata: { probe_key: 'probe-value' } } });
89
+ fx.machineId = String(m.body.id);
90
+ const m2 = await call('POST', `/v1/apps/${APP}/machines`, { config: { image: 'nginx:alpine' }, skip_launch: true });
91
+ fx.stoppedMachineId = String(m2.body.id);
92
+ const v = await call('POST', `/v1/apps/${APP}/volumes`, { name: 'probe_vol', size_gb: 2 });
93
+ fx.volumeId = String(v.body.id);
94
+ const doomed = await call('POST', `/v1/apps/${APP}/volumes`, { name: 'probe_vol_doomed', size_gb: 1 });
95
+ fx.doomedVolumeId = String(doomed.body.id);
96
+ const lease = await call('POST', `/v1/apps/${APP}/machines/${fx.machineId}/lease`, { ttl: 3600 });
97
+ fx.leaseNonce = String(lease.body.data.nonce);
98
+ await call('POST', `/v1/apps/${APP}/secrets/PROBE_SECRET`, { value: 'hidden' });
99
+ await call('POST', `/v1/apps/${APP}/secrets/PROBE_SECRET_DOOMED`, { value: 'doomed' });
100
+ // Two-way bijection: every snapshot endpoint has a probe, every probe names a snapshot endpoint.
101
+ for (const endpoint of snapshot.implementedEndpoints) {
102
+ if (!PROBES[endpoint])
103
+ violations.push(`endpoint '${endpoint}' is claimed but has no probe`);
104
+ }
105
+ for (const key of Object.keys(PROBES)) {
106
+ if (!snapshot.implementedEndpoints.includes(key))
107
+ violations.push(`probe '${key}' targets an endpoint the snapshot does not claim`);
108
+ }
109
+ for (const [endpoint, probe] of Object.entries(PROBES)) {
110
+ probed++;
111
+ const path = typeof probe.path === 'function' ? probe.path() : probe.path;
112
+ const headers = typeof probe.headers === 'function' ? probe.headers() : probe.headers;
113
+ // The lease-guard means every state-mutating probe on the leased fixture machine must carry
114
+ // the nonce; passing it unconditionally is harmless for the rest.
115
+ const withNonce = fx.leaseNonce ? { 'fly-machine-lease-nonce': fx.leaseNonce, ...(headers ?? {}) } : headers;
116
+ const res = await call(probe.method, path, probe.body, withNonce);
117
+ if (!probe.status.includes(res.status)) {
118
+ violations.push(`${endpoint}: expected status ${probe.status.join('/')} but got ${res.status} (${JSON.stringify(res.body)?.slice(0, 200)})`);
119
+ continue;
120
+ }
121
+ if (probe.expect && !probe.expect(res.body)) {
122
+ violations.push(`${endpoint}: status ok but the body failed the probe's shape predicate (${JSON.stringify(res.body)?.slice(0, 200)})`);
123
+ }
124
+ }
125
+ }
126
+ finally {
127
+ rmSync(root, { recursive: true, force: true });
128
+ }
129
+ // LITERAL census (never the constant the handler itself exports — that was tinybird's
130
+ // two-constants false-green): the resource types this twin is expected to project.
131
+ for (const type of ['app', 'machine', 'volume', 'secret']) {
132
+ if (!snapshot.resourceTypes.includes(type))
133
+ violations.push(`resource type '${type}' missing from snapshot`);
134
+ }
135
+ return {
136
+ ok: violations.length === 0,
137
+ endpointsChecked: snapshot.implementedEndpoints.length,
138
+ endpointsProbed: probed,
139
+ resourceTypesChecked: snapshot.resourceTypes.length,
140
+ violations,
141
+ };
142
+ }
@@ -0,0 +1,75 @@
1
+ import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
2
+ import { FlyBudget, type FlyBudgetOptions } from './fly-budget.js';
3
+ /**
4
+ * The injected real-Fly boundary. One Machines API call:
5
+ * method — 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
6
+ * path — e.g. '/v1/apps?org_slug=personal' or '/v1/apps/my-app/machines'
7
+ * Returns the HTTP status + parsed JSON body. Status-bearing BY DESIGN: a refused/failed pull
8
+ * must be distinguishable from a genuinely empty account (ADDING_A_TWIN §6 — "a REFUSED pull is
9
+ * NOT an empty account"), so every pull helper below THROWS on a non-2xx instead of folding an
10
+ * empty list over real observed state.
11
+ */
12
+ export type FlyExecute = (method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, body?: Record<string, unknown>) => Promise<{
13
+ status: number;
14
+ body: any;
15
+ }>;
16
+ /** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
17
+ export type LiveFlyOptions = {
18
+ /** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
19
+ fetchImpl?: typeof fetch;
20
+ /** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
21
+ budget?: FlyBudget;
22
+ /** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
23
+ budgetOptions?: FlyBudgetOptions;
24
+ };
25
+ /**
26
+ * A live executor against real Fly (token = a Fly.io API token, `fly tokens deploy` / org token).
27
+ * Never imported by the pack's own runtime path — only constructed by a caller opting into real
28
+ * I/O. THIS IS THE ONE PLACE this pack issues a live api.machines.dev request, and the Machines
29
+ * API is a particularly bad place to burst: its writes PROVISION AND BILL real compute. EVERY
30
+ * call is guarded: the budget is charged BEFORE the request goes out (`checkBudget` THROWS
31
+ * `FlyBudgetError` instead of returning when the ceiling or a persisted cooldown says stop) and
32
+ * the response is fed back (`recordCall`) so a `Retry-After`/429 becomes a persisted cooldown
33
+ * that makes every later call fail fast WITHOUT touching Fly. There is deliberately no option
34
+ * that disables the guard; an injected `budget` must be an UNMODIFIED FlyBudget
35
+ * (`assertBudgetGuardIntact` — method identity, not `instanceof`; a subclass overriding
36
+ * `checkBudget` or a Proxy trapping it is refused).
37
+ */
38
+ export declare function liveFlyExecute(token: string, base?: string, opts?: LiveFlyOptions): FlyExecute;
39
+ export declare function mapApp(orgSlug: string, a: Record<string, unknown>): SyncResource;
40
+ export declare function mapMachine(appName: string, m: Record<string, unknown>): SyncResource;
41
+ export declare function mapVolume(appName: string, v: Record<string, unknown>): SyncResource;
42
+ export declare function pullFlyApps(execute: FlyExecute, orgSlug: string): Promise<SyncResource[]>;
43
+ export declare function pullFlyMachines(execute: FlyExecute, appName: string): Promise<SyncResource[]>;
44
+ export declare function pullFlyVolumes(execute: FlyExecute, appName: string): Promise<SyncResource[]>;
45
+ /**
46
+ * D7 entry point: pull the real account's apps (and each app's machines + volumes) and fold them
47
+ * into the twin via ONE `observeResources` batch (the fold's own diff). Returns `{observed, deltasAppended}` — a
48
+ * re-pull of identical state appends ZERO deltas. `occurredAt` defaults to a MOVING now (never a
49
+ * pinned constant — a pinned pull default makes a reverting vendor value collide with its own
50
+ * earlier observation and vanish as a phantom delta; ADDING_A_TWIN §6).
51
+ */
52
+ export declare function syncFlyFromReal(execute: FlyExecute, opts?: {
53
+ root?: string;
54
+ occurredAt?: string;
55
+ orgSlug?: string;
56
+ }): Promise<{
57
+ observed: number;
58
+ deltasAppended: number;
59
+ }>;
60
+ /** The pack's executor over the kernel's: the same Machines API call, carried by the head. */
61
+ export declare function flyExecuteOver(execute: RemoteExecute): FlyExecute;
62
+ /** The refresh adapter: pull the org's apps, machines and volumes through the executor and fold them into the root. */
63
+ export declare function syncFlyFromRemote(execute: RemoteExecute, opts?: {
64
+ root?: string;
65
+ origin?: string;
66
+ occurredAt?: string;
67
+ orgSlug?: string;
68
+ }): Promise<{
69
+ observed: number;
70
+ deltasAppended: number;
71
+ }>;
72
+ /** The perform adapter: one entry crosses to Fly through the executor — an app, a volume, a machine and its
73
+ * lifecycle, a secret. An entry that is the twin's own (a lease, a purge, flyd's fold of a container exit, a
74
+ * snapshot) crosses nothing. A referenced id (a mount's volume, a path's machine) is the vendor's by then. */
75
+ export declare function performFlyAction(execute: RemoteExecute, action: TwinAction, ctx: PerformContext): Promise<PushOutcome>;
@@ -0,0 +1,277 @@
1
+ // fly CONNECTOR — the live-vendor pull path that gives the fly twin the "git for SaaS"
2
+ // lifecycle over an INJECTED executor (the auth boundary).
3
+ //
4
+ // PULL (real → twin): fetch the real account's apps (then each app's machines + volumes) over
5
+ // the injected executor, map → SyncResource[], fold into the tree via `observeResources`
6
+ // (shadow-diff dedup, so a re-pull of identical state appends nothing) — D6/D7.
7
+ //
8
+ // PERFORM (twin → real) is the head's, at protocol 2: `performFlyAction` carries ONE entry across
9
+ // through the kernel's executor — and only when the head is bound to a real state system, an
10
+ // operator's deliberate act, because a performed machine-create provisions real BILLABLE compute.
11
+ //
12
+ // The vendor I/O is an INJECTED executor: a fake in tests, `liveFlyExecute(token)` in prod —
13
+ // the ONE place this pack issues a live api.machines.dev request, and therefore the one place
14
+ // the rate budget is enforced (D8). The pack imports NO SDK and holds NO token.
15
+ import { assertBudgetGuardIntact, observeResources } from '@volter/world-core';
16
+ import { FlyBudget, FlyBudgetError, flyCallWeight } from "./fly-budget.js";
17
+ const SERVICE = 'fly';
18
+ /**
19
+ * A live executor against real Fly (token = a Fly.io API token, `fly tokens deploy` / org token).
20
+ * Never imported by the pack's own runtime path — only constructed by a caller opting into real
21
+ * I/O. THIS IS THE ONE PLACE this pack issues a live api.machines.dev request, and the Machines
22
+ * API is a particularly bad place to burst: its writes PROVISION AND BILL real compute. EVERY
23
+ * call is guarded: the budget is charged BEFORE the request goes out (`checkBudget` THROWS
24
+ * `FlyBudgetError` instead of returning when the ceiling or a persisted cooldown says stop) and
25
+ * the response is fed back (`recordCall`) so a `Retry-After`/429 becomes a persisted cooldown
26
+ * that makes every later call fail fast WITHOUT touching Fly. There is deliberately no option
27
+ * that disables the guard; an injected `budget` must be an UNMODIFIED FlyBudget
28
+ * (`assertBudgetGuardIntact` — method identity, not `instanceof`; a subclass overriding
29
+ * `checkBudget` or a Proxy trapping it is refused).
30
+ */
31
+ export function liveFlyExecute(token, base = 'https://api.machines.dev', opts = {}) {
32
+ const doFetch = opts.fetchImpl ?? fetch;
33
+ // The default ledger is keyed by a hash of THIS token — Fly scopes limits under the account
34
+ // the token names, so a cwd-scoped ledger would hand the same token a fresh allowance per
35
+ // checkout/worktree/CI leg.
36
+ const budget = opts.budget !== undefined && opts.budget !== null
37
+ ? assertBudgetGuardIntact(opts.budget, FlyBudget, 'liveFlyExecute')
38
+ : new FlyBudget({ token, ...(opts.budgetOptions ?? {}) });
39
+ return async (method, path, reqBody) => {
40
+ const headers = { Authorization: `Bearer ${token}` };
41
+ const init = { method, headers };
42
+ if (method !== 'GET' && reqBody) {
43
+ headers['Content-Type'] = 'application/json';
44
+ init.body = JSON.stringify(reqBody);
45
+ }
46
+ const weight = flyCallWeight(method, path.split('?')[0] ?? path);
47
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
48
+ const reservation = budget.checkBudget(weight);
49
+ const res = await doFetch(`${base}${path}`, init);
50
+ const resHeaders = {};
51
+ res.headers.forEach((v, k) => { resHeaders[k.toLowerCase()] = v; });
52
+ const text = await res.text();
53
+ // Settles the reservation and, on a back-off signal, arms the persisted cooldown (may itself
54
+ // throw a louder refusal — the cooldown is persisted first either way).
55
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
56
+ // call that louder refusal wins; an answer Fly ACCEPTED is kept, so a write that landed is
57
+ // never recorded as failed and performed again on retry.
58
+ try {
59
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
60
+ }
61
+ catch (error) {
62
+ if (!(error instanceof FlyBudgetError) || !res.ok)
63
+ throw error;
64
+ }
65
+ let body = {};
66
+ if (text) {
67
+ try {
68
+ body = JSON.parse(text);
69
+ }
70
+ catch {
71
+ body = { error: text };
72
+ }
73
+ }
74
+ return { status: res.status, body };
75
+ };
76
+ }
77
+ /** A non-2xx pull reply is a REFUSAL, never an empty account — throw, don't fold. */
78
+ function refuse(what, res) {
79
+ const msg = res.body && typeof res.body === 'object' && typeof res.body.error === 'string' ? res.body.error : JSON.stringify(res.body);
80
+ throw new Error(`fly pull ${what} refused: HTTP ${res.status} ${msg}`);
81
+ }
82
+ // ── Mapping (real Fly → twin SyncResource) — pure, no client (the mutation-test connector
83
+ // sweep leaves map* real by convention) ───────────────────────────────────────────────────────
84
+ export function mapApp(orgSlug, a) {
85
+ return {
86
+ type: 'app',
87
+ id: String(a.name ?? a.id),
88
+ fields: {
89
+ org_slug: orgSlug,
90
+ org_name: a.organization?.name ?? orgSlug,
91
+ network: a.network ?? 'default',
92
+ internal_numeric_id: a.internal_numeric_id ?? null,
93
+ status: a.status ?? 'pending',
94
+ },
95
+ };
96
+ }
97
+ export function mapMachine(appName, m) {
98
+ return {
99
+ type: 'machine',
100
+ id: String(m.id),
101
+ fields: {
102
+ app_name: appName,
103
+ name: m.name ?? '',
104
+ region: m.region ?? '',
105
+ state: m.state ?? 'stopped',
106
+ instance_id: m.instance_id ?? '',
107
+ private_ip: m.private_ip ?? '',
108
+ created_at: m.created_at ?? null,
109
+ machine_updated_at: m.updated_at ?? null,
110
+ config: m.config ?? {},
111
+ image_ref: m.image_ref ?? {},
112
+ events: Array.isArray(m.events) ? [...m.events].reverse() : [], // the wire is newest-first; the row is in order
113
+ cordoned: m.cordoned === true,
114
+ // A pulled machine runs on REAL Fly, not on any local execution plane: the runtime kind records
115
+ // the fact honestly. The twin's own bookkeeping (`_`-prefixed) is outside the vendor's shape.
116
+ _twin_runtime: 'real-fly',
117
+ },
118
+ };
119
+ }
120
+ export function mapVolume(appName, v) {
121
+ return {
122
+ type: 'volume',
123
+ id: String(v.id),
124
+ fields: {
125
+ app_name: appName,
126
+ name: v.name ?? '',
127
+ region: v.region ?? '',
128
+ size_gb: v.size_gb ?? 1,
129
+ encrypted: v.encrypted !== false,
130
+ fstype: v.fstype ?? 'ext4',
131
+ snapshot_retention: v.snapshot_retention ?? 5,
132
+ auto_backup_enabled: v.auto_backup_enabled !== false,
133
+ zone: v.zone ?? '',
134
+ state: v.state ?? 'created',
135
+ attached_machine_id: v.attached_machine_id ?? null,
136
+ created_at: v.created_at ?? null,
137
+ snapshots: [],
138
+ },
139
+ };
140
+ }
141
+ // ── PULL ──────────────────────────────────────────────────────────────────────────────────────
142
+ export async function pullFlyApps(execute, orgSlug) {
143
+ const res = await execute('GET', `/v1/apps?org_slug=${encodeURIComponent(orgSlug)}`);
144
+ if (res.status < 200 || res.status >= 300)
145
+ refuse('apps', res);
146
+ const apps = Array.isArray(res.body?.apps) ? res.body.apps : [];
147
+ // the list is the short shape (id, name, machine_count, network, status); the App itself — its internal
148
+ // numeric id, its organization — answers at /v1/apps/{name}, one read per app
149
+ const out = [];
150
+ for (const a of apps) {
151
+ const name = String(a.name ?? a.id);
152
+ const detail = await execute('GET', `/v1/apps/${encodeURIComponent(name)}`);
153
+ if (detail.status < 200 || detail.status >= 300)
154
+ refuse(`app ${name}`, detail);
155
+ out.push(mapApp(orgSlug, { ...a, ...detail.body }));
156
+ }
157
+ return out;
158
+ }
159
+ export async function pullFlyMachines(execute, appName) {
160
+ const res = await execute('GET', `/v1/apps/${encodeURIComponent(appName)}/machines`);
161
+ if (res.status < 200 || res.status >= 300)
162
+ refuse(`machines of ${appName}`, res);
163
+ const machines = Array.isArray(res.body) ? res.body : [];
164
+ return machines.map((m) => mapMachine(appName, m));
165
+ }
166
+ export async function pullFlyVolumes(execute, appName) {
167
+ const res = await execute('GET', `/v1/apps/${encodeURIComponent(appName)}/volumes`);
168
+ if (res.status < 200 || res.status >= 300)
169
+ refuse(`volumes of ${appName}`, res);
170
+ const volumes = Array.isArray(res.body) ? res.body : [];
171
+ return volumes.map((v) => mapVolume(appName, v));
172
+ }
173
+ /**
174
+ * D7 entry point: pull the real account's apps (and each app's machines + volumes) and fold them
175
+ * into the twin via ONE `observeResources` batch (the fold's own diff). Returns `{observed, deltasAppended}` — a
176
+ * re-pull of identical state appends ZERO deltas. `occurredAt` defaults to a MOVING now (never a
177
+ * pinned constant — a pinned pull default makes a reverting vendor value collide with its own
178
+ * earlier observation and vanish as a phantom delta; ADDING_A_TWIN §6).
179
+ */
180
+ export async function syncFlyFromReal(execute, opts = {}) {
181
+ const orgSlug = opts.orgSlug ?? 'personal';
182
+ const occurredAt = opts.occurredAt ?? new Date().toISOString();
183
+ const apps = await pullFlyApps(execute, orgSlug);
184
+ const nested = [];
185
+ for (const app of apps) {
186
+ const appName = app.id;
187
+ const [machines, volumes] = await Promise.all([
188
+ pullFlyMachines(execute, appName),
189
+ pullFlyVolumes(execute, appName),
190
+ ]);
191
+ nested.push(...machines, ...volumes);
192
+ }
193
+ const resources = [...apps, ...nested];
194
+ // protocol 2: an observation lands on the head through the kernel's fold — one batch, one instant
195
+ const report = observeResources(SERVICE, resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields })), { ...(opts.root !== undefined ? { root: opts.root } : {}), at: occurredAt, batch: `obs:${SERVICE}:${occurredAt}` });
196
+ return { observed: report.observed, deltasAppended: report.appended };
197
+ }
198
+ // ── PROTOCOL 2: the state system's two adapters, over the kernel's executor ──────────
199
+ /** The pack's executor over the kernel's: the same Machines API call, carried by the head. */
200
+ export function flyExecuteOver(execute) {
201
+ return async (method, path, body) => {
202
+ // the wire wants a bearer: at a real boundary the kernel's executor sets the sealed credential over this
203
+ // header (executor.ts — a pack never holds one); at the twin's own wire, refreshed from itself, any bearer is a bearer
204
+ const res = await execute({ method, path, headers: { accept: 'application/json', authorization: 'Bearer twin', ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
205
+ let parsed = {};
206
+ if (res.body) {
207
+ try {
208
+ parsed = JSON.parse(res.body);
209
+ }
210
+ catch {
211
+ parsed = { error: res.body };
212
+ }
213
+ }
214
+ return { status: res.status, body: parsed };
215
+ };
216
+ }
217
+ /** The refresh adapter: pull the org's apps, machines and volumes through the executor and fold them into the root. */
218
+ export async function syncFlyFromRemote(execute, opts = {}) {
219
+ return syncFlyFromReal(flyExecuteOver(execute), { ...(opts.root !== undefined ? { root: opts.root } : {}), ...(opts.orgSlug !== undefined ? { orgSlug: opts.orgSlug } : {}), occurredAt: opts.occurredAt ?? new Date().toISOString() });
220
+ }
221
+ /** The perform adapter: one entry crosses to Fly through the executor — an app, a volume, a machine and its
222
+ * lifecycle, a secret. An entry that is the twin's own (a lease, a purge, flyd's fold of a container exit, a
223
+ * snapshot) crosses nothing. A referenced id (a mount's volume, a path's machine) is the vendor's by then. */
224
+ export async function performFlyAction(execute, action, ctx) {
225
+ const fly = flyExecuteOver(execute);
226
+ const op = action.operation ?? `${action.subject.type}.update`;
227
+ const f = action.fields ?? {};
228
+ const app = encodeURIComponent(String(f.app_name ?? ''));
229
+ const answer = async (method, path, body) => {
230
+ const res = await fly(method, path, body);
231
+ if (res.status < 200 || res.status >= 300)
232
+ throw new Error(`fly perform ${op} refused: HTTP ${res.status} ${JSON.stringify(res.body).slice(0, 200)}`);
233
+ return (res.body ?? {});
234
+ };
235
+ const machineId = () => encodeURIComponent(ctx.resolve('machine', action.subject.id));
236
+ const volumeId = () => encodeURIComponent(ctx.resolve('volume', action.subject.id));
237
+ switch (op) {
238
+ case 'app.create':
239
+ await answer('POST', '/v1/apps', { app_name: action.subject.id, org_slug: f.org_slug ?? 'personal', network: f.network ?? 'default' });
240
+ return { externalId: action.subject.id };
241
+ case 'app.delete':
242
+ await answer('DELETE', `/v1/apps/${encodeURIComponent(action.subject.id)}`);
243
+ return { externalId: action.subject.id };
244
+ case 'volume.create': {
245
+ const v = await answer('POST', `/v1/apps/${app}/volumes`, { name: f.name, region: f.region, size_gb: f.size_gb, encrypted: f.encrypted, fstype: f.fstype, snapshot_retention: f.snapshot_retention, auto_backup_enabled: f.auto_backup_enabled });
246
+ return { externalId: String(v.id ?? action.subject.id), data: v };
247
+ }
248
+ case 'volume.delete':
249
+ await answer('DELETE', `/v1/apps/${app}/volumes/${volumeId()}`);
250
+ return { externalId: ctx.resolve('volume', action.subject.id) };
251
+ case 'machine.create': {
252
+ const config = { ...(f.config ?? {}) };
253
+ if (Array.isArray(config.mounts))
254
+ config.mounts = config.mounts.map((m) => (typeof m.volume === 'string' ? { ...m, volume: ctx.resolve('volume', m.volume) } : m));
255
+ const m = await answer('POST', `/v1/apps/${app}/machines`, { name: f.name, region: f.region, config, ...(f.state === 'created' ? { skip_launch: true } : {}) });
256
+ return { externalId: String(m.id ?? action.subject.id), data: m };
257
+ }
258
+ case 'machine.update':
259
+ await answer('POST', `/v1/apps/${app}/machines/${machineId()}`, { config: f.config });
260
+ return { externalId: ctx.resolve('machine', action.subject.id) };
261
+ case 'machine.start':
262
+ case 'machine.stop':
263
+ case 'machine.suspend':
264
+ case 'machine.restart':
265
+ case 'machine.cordon':
266
+ case 'machine.uncordon':
267
+ await answer('POST', `/v1/apps/${app}/machines/${machineId()}/${op.slice('machine.'.length)}`);
268
+ return { externalId: ctx.resolve('machine', action.subject.id) };
269
+ case 'machine.destroy':
270
+ await answer('DELETE', `/v1/apps/${app}/machines/${machineId()}?force=true`);
271
+ return { externalId: ctx.resolve('machine', action.subject.id) };
272
+ case 'secret.set':
273
+ await answer('POST', `/v1/apps/${app}/secrets`, { values: { [String(f.secret_name)]: f.value } });
274
+ return { externalId: action.subject.id };
275
+ default: return { externalId: action.subject.id, data: { performed: false, reason: `${op} is the twin's own record — nothing at Fly to write` } };
276
+ }
277
+ }
@@ -0,0 +1,121 @@
1
+ import type { FlyContainerRuntime, FlyContainerSpec, FlyExecResult } from './fly-machines.js';
2
+ export declare const FLY_DOCKER_LABEL = "dev.volter.fly-twin";
3
+ /** MiB of writable storage admission keeps FREE beyond the machine's own need — the DEFAULT of a
4
+ * configured mechanism value, not a constant. A World whose host disk is smaller than the
5
+ * reserve configures it down (`world-fly serve --storage-reserve-mib N`,
6
+ * `FlyDockerRuntimeOptions.storageReserveMiB`); the runtime never lowers its own floor. */
7
+ export declare const DEFAULT_STORAGE_RESERVE_MIB = 2048;
8
+ /** Options of the REAL execution plane. Configuration reaches this runtime the way the plane
9
+ * itself does — resolved at the HOST BOUNDARY (cli.ts via fly-runtime-choice.ts) and handed in,
10
+ * never read from the process here. */
11
+ export interface FlyDockerRuntimeOptions {
12
+ /** MiB kept free beyond a machine's own writable-storage need (default 2048). */
13
+ storageReserveMiB?: number;
14
+ }
15
+ /**
16
+ * The writable-storage floor for ONE machine: its own need (never below an image pull plus a
17
+ * writable layer) plus the reserve the host keeps free. Pure arithmetic, so the admission rule is
18
+ * checkable without a daemon.
19
+ */
20
+ export declare function writableStorageFloorMiB(memoryMiB: number, reserveMiB?: number): {
21
+ requiredMiB: number;
22
+ reserveMiB: number;
23
+ floorMiB: number;
24
+ };
25
+ /** What the writable-storage probe measured, and the SUBSTRATE it measured it on (named in the
26
+ * capacity log so a refusal is diagnosable; the caller's error stays in World vocabulary). */
27
+ export type WritableStorage = {
28
+ availableMiB: number;
29
+ substrate: string;
30
+ };
31
+ /**
32
+ * The host facts the writable-storage probe reads. Injected — the same seam doctrine the
33
+ * execution plane itself uses — so every branch (host-visible docker root, Colima, OrbStack,
34
+ * Docker Desktop) is provable OFFLINE against fakes, with no daemon anywhere.
35
+ */
36
+ export interface StorageProbeHost {
37
+ readonly platform: string;
38
+ readonly home: string;
39
+ /** Does this HOST path exist? */
40
+ exists(path: string): boolean;
41
+ /** Free MiB on the host volume holding `path`; undefined when it cannot be read. */
42
+ freeMiB(path: string): number | undefined;
43
+ /** Entries of a host directory; [] when unreadable. */
44
+ entries(path: string): string[];
45
+ docker(args: string[]): {
46
+ status: number;
47
+ stdout: string;
48
+ stderr: string;
49
+ };
50
+ run(command: string, args: string[], timeoutMs: number): {
51
+ status: number | null;
52
+ stdout: string;
53
+ stderr: string;
54
+ };
55
+ warn(line: string): void;
56
+ }
57
+ /**
58
+ * How much writable storage the local execution substrate can still take, measured INSIDE that
59
+ * substrate when the docker root is not host-visible. Honest by construction: every branch either
60
+ * MEASURES a real filesystem or returns undefined (admission then refuses) — capacity is never
61
+ * assumed, defaulted or fabricated.
62
+ */
63
+ export declare function probeWritableStorage(rootDir: string, host?: StorageProbeHost): WritableStorage | undefined;
64
+ /** Is a Docker DAEMON actually reachable (not just the binary present)? */
65
+ export declare function dockerAvailable(): boolean;
66
+ export declare function flyContainerName(machineId: string): string;
67
+ export declare function flyVolumeName(volumeId: string): string;
68
+ export declare class FlyDockerRuntime implements FlyContainerRuntime {
69
+ #private;
70
+ readonly kind = "docker";
71
+ constructor(root?: string, options?: FlyDockerRuntimeOptions);
72
+ run(spec: FlyContainerSpec): Promise<{
73
+ containerRef: string;
74
+ publishedPorts?: Array<{
75
+ internal: number;
76
+ host: number;
77
+ }>;
78
+ }>;
79
+ stop(containerRef: string, opts?: {
80
+ signal?: string;
81
+ timeoutSeconds?: number;
82
+ }): Promise<void>;
83
+ /**
84
+ * Start a STOPPED machine — by RECREATING the container, never `docker start`.
85
+ *
86
+ * Real Fly resets the root filesystem on stop→start: "Stopped Machines that are restarted are
87
+ * completely reset to their original state so that they start clean on the next run"
88
+ * (fly.io/docs/machines/api/machines-resource). `docker start` resumes the old writable layer,
89
+ * which would make this twin MORE FORGIVING than the vendor at exactly the point where the
90
+ * difference bites: an app that writes state outside a volume would pass local rehearsal and
91
+ * lose that state on the first real stop/start cycle.
92
+ *
93
+ * So the old container is removed and a fresh one is run from `spec.image` with the same
94
+ * env/ports/mounts wiring. `docker rm -v` removes only ANONYMOUS volumes, so the named
95
+ * `fly-twin-<volume id>` volumes (this twin's mapping of Fly volumes) carry over — mounted
96
+ * data survives, everything else starts clean, exactly like the vendor. The new container gets
97
+ * a new handle and new ephemeral loopback ports; both are returned and re-ledgered.
98
+ */
99
+ start(containerRef: string, spec: FlyContainerSpec): Promise<{
100
+ containerRef: string;
101
+ publishedPorts?: Array<{
102
+ internal: number;
103
+ host: number;
104
+ }>;
105
+ }>;
106
+ remove(containerRef: string): Promise<void>;
107
+ pause(containerRef: string): Promise<void>;
108
+ unpause(containerRef: string): Promise<void>;
109
+ signal(containerRef: string, signal: string): Promise<void>;
110
+ exec(containerRef: string, cmd: string[], opts?: {
111
+ timeoutSeconds?: number;
112
+ }): Promise<FlyExecResult>;
113
+ inspect(containerRef: string): Promise<{
114
+ running: boolean;
115
+ exitCode?: number;
116
+ oomKilled?: boolean;
117
+ }>;
118
+ createVolume(volumeId: string): Promise<void>;
119
+ removeVolume(volumeId: string): Promise<void>;
120
+ cleanup(): Promise<void>;
121
+ }