@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,168 @@
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.ts';
14
+
15
+ export type FlyConformanceReport = {
16
+ ok: boolean;
17
+ endpointsChecked: number;
18
+ endpointsProbed: number;
19
+ resourceTypesChecked: number;
20
+ violations: string[];
21
+ };
22
+
23
+ type Probe = {
24
+ method: string;
25
+ /** A literal path, or a thunk resolved after seeding (fixture ids are minted by seed). */
26
+ path: string | (() => string);
27
+ body?: unknown;
28
+ headers?: Record<string, string> | (() => Record<string, string>);
29
+ /** The status(es) a WORKING handler answers with. */
30
+ status: number[];
31
+ /** What a working handler's body must look like. Omitted only for the empty-body statuses. */
32
+ expect?: (body: unknown) => boolean;
33
+ };
34
+
35
+ const isObject = (b: unknown): b is Record<string, unknown> => !!b && typeof b === 'object' && !Array.isArray(b);
36
+ const hasKeys = (...keys: string[]) => (b: unknown) => isObject(b) && keys.every((k) => b[k] !== undefined);
37
+
38
+ // Fixture ids minted by seeding, addressed by the probes.
39
+ const APP = 'probe-app';
40
+ const fx = { machineId: '', stoppedMachineId: '', volumeId: '', doomedVolumeId: '', leaseNonce: '' };
41
+
42
+ /** A representative request per declared endpoint. DESTRUCTIVE probes target throwaway fixtures
43
+ * of their own; the shared fixture machine's probes are ORDER-DEPENDENT by design (probes run in
44
+ * declaration order) and each order dependency is stated inline where it is load-bearing. */
45
+ const PROBES: Record<string, Probe> = {
46
+ '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' },
47
+ 'POST /v1/apps': { method: 'POST', path: '/v1/apps', body: { app_name: 'probe-app-created', org_slug: 'personal' }, status: [201] },
48
+ 'GET /v1/apps/{app_name}': { method: 'GET', path: `/v1/apps/${APP}`, status: [200], expect: (b) => isObject(b) && b.name === APP && isObject(b.organization) },
49
+ 'DELETE /v1/apps/{app_name}': { method: 'DELETE', path: '/v1/apps/probe-app-doomed', status: [202] },
50
+ 'GET /v1/apps/{app_name}/machines': { method: 'GET', path: `/v1/apps/${APP}/machines`, status: [200], expect: (b) => Array.isArray(b) && b.some((m) => (m as Record<string, unknown>).id === fx.machineId) },
51
+ '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' },
52
+ '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) },
53
+ '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 as Record<string, unknown>).image === 'nginx:1.27-alpine' },
54
+ '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 },
55
+ // ORDER: stop first, so the start probe below exercises the REAL start path (previous_state
56
+ // "stopped"), not the idempotent already-started short-circuit.
57
+ '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 },
58
+ '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' },
59
+ '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 },
60
+ '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 },
61
+ // Probes run in declaration order, so exec probes while the machine is still STARTED (signal
62
+ // above leaves it started; suspend below takes it out of exec's reach). The default (virtual)
63
+ // execution plane cannot execute; the honest, documented outcome is the vendor-enveloped 400
64
+ // naming the virtual plane — never a fabricated {exit_code:0}.
65
+ '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 as string).includes('virtual') },
66
+ '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 },
67
+ '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 },
68
+ '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 },
69
+ // After suspend the fixture machine is suspended, so wait?state=suspended is the honest probe
70
+ // of the wait mechanism itself (already-there → 200 {ok:true}).
71
+ '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 },
72
+ '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]) },
73
+ '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]) },
74
+ '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 as Record<string, unknown>).nonce === 'string' },
75
+ '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' },
76
+ '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' },
77
+ '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' },
78
+ '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' },
79
+ '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] },
80
+ 'DELETE /v1/apps/{app_name}/machines/{machine_id}/metadata/{key}': { method: 'DELETE', path: () => `/v1/apps/${APP}/machines/${fx.machineId}/metadata/probe_key2`, status: [204] },
81
+ 'GET /v1/apps/{app_name}/volumes': { method: 'GET', path: `/v1/apps/${APP}/volumes`, status: [200], expect: (b) => Array.isArray(b) && b.some((v) => (v as Record<string, unknown>).id === fx.volumeId) },
82
+ '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 as string).startsWith('vol_') },
83
+ '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' },
84
+ '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 },
85
+ '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' },
86
+ '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 as Record<string, unknown>).size_gb === 4 && typeof b.needs_restart === 'boolean' },
87
+ // ORDER: POST first — the GET after it is what gives the (empty-bodied) create its teeth: a
88
+ // handler stubbed to `return {status:200, body:{}}` would leave the list empty and go red here.
89
+ 'POST /v1/apps/{app_name}/volumes/{volume_id}/snapshots': { method: 'POST', path: () => `/v1/apps/${APP}/volumes/${fx.volumeId}/snapshots`, status: [200] },
90
+ '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] as Record<string, unknown>).id).startsWith('vs_') },
91
+ 'GET /v1/apps/{app_name}/secrets': { method: 'GET', path: `/v1/apps/${APP}/secrets`, status: [200], expect: (b) => isObject(b) && Array.isArray(b.secrets) },
92
+ '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' },
93
+ '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 },
94
+ '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' },
95
+ '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' },
96
+ };
97
+
98
+ export async function checkFlyConformance(): Promise<FlyConformanceReport> {
99
+ const snapshot = flyTwinSnapshot();
100
+ const violations: string[] = [];
101
+ const root = mkdtempSync(join(tmpdir(), 'fly-conformance-'));
102
+ const at = '2026-02-01T00:00:00.000Z';
103
+ const call = (method: string, path: string, body?: unknown, headers?: Record<string, string>) =>
104
+ handleFlyTwinRequest({
105
+ method, path, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), root, occurredAt: at,
106
+ headers: { authorization: 'Bearer probe-token', ...(headers ?? {}) },
107
+ });
108
+
109
+ let probed = 0;
110
+ try {
111
+ // Seed the fixtures the probes address, through the twin's own write path.
112
+ await call('POST', '/v1/apps', { app_name: APP, org_slug: 'personal' });
113
+ await call('POST', '/v1/apps', { app_name: 'probe-app-doomed', org_slug: 'personal' });
114
+ const m = await call('POST', `/v1/apps/${APP}/machines`, { config: { image: 'nginx:alpine', metadata: { probe_key: 'probe-value' } } });
115
+ fx.machineId = String((m.body as Record<string, unknown>).id);
116
+ const m2 = await call('POST', `/v1/apps/${APP}/machines`, { config: { image: 'nginx:alpine' }, skip_launch: true });
117
+ fx.stoppedMachineId = String((m2.body as Record<string, unknown>).id);
118
+ const v = await call('POST', `/v1/apps/${APP}/volumes`, { name: 'probe_vol', size_gb: 2 });
119
+ fx.volumeId = String((v.body as Record<string, unknown>).id);
120
+ const doomed = await call('POST', `/v1/apps/${APP}/volumes`, { name: 'probe_vol_doomed', size_gb: 1 });
121
+ fx.doomedVolumeId = String((doomed.body as Record<string, unknown>).id);
122
+ const lease = await call('POST', `/v1/apps/${APP}/machines/${fx.machineId}/lease`, { ttl: 3600 });
123
+ fx.leaseNonce = String(((lease.body as Record<string, unknown>).data as Record<string, unknown>).nonce);
124
+ await call('POST', `/v1/apps/${APP}/secrets/PROBE_SECRET`, { value: 'hidden' });
125
+ await call('POST', `/v1/apps/${APP}/secrets/PROBE_SECRET_DOOMED`, { value: 'doomed' });
126
+
127
+ // Two-way bijection: every snapshot endpoint has a probe, every probe names a snapshot endpoint.
128
+ for (const endpoint of snapshot.implementedEndpoints) {
129
+ if (!PROBES[endpoint]) violations.push(`endpoint '${endpoint}' is claimed but has no probe`);
130
+ }
131
+ for (const key of Object.keys(PROBES)) {
132
+ if (!snapshot.implementedEndpoints.includes(key)) violations.push(`probe '${key}' targets an endpoint the snapshot does not claim`);
133
+ }
134
+
135
+ for (const [endpoint, probe] of Object.entries(PROBES)) {
136
+ probed++;
137
+ const path = typeof probe.path === 'function' ? probe.path() : probe.path;
138
+ const headers = typeof probe.headers === 'function' ? probe.headers() : probe.headers;
139
+ // The lease-guard means every state-mutating probe on the leased fixture machine must carry
140
+ // the nonce; passing it unconditionally is harmless for the rest.
141
+ const withNonce = fx.leaseNonce ? { 'fly-machine-lease-nonce': fx.leaseNonce, ...(headers ?? {}) } : headers;
142
+ const res = await call(probe.method, path, probe.body, withNonce);
143
+ if (!probe.status.includes(res.status)) {
144
+ violations.push(`${endpoint}: expected status ${probe.status.join('/')} but got ${res.status} (${JSON.stringify(res.body)?.slice(0, 200)})`);
145
+ continue;
146
+ }
147
+ if (probe.expect && !probe.expect(res.body)) {
148
+ violations.push(`${endpoint}: status ok but the body failed the probe's shape predicate (${JSON.stringify(res.body)?.slice(0, 200)})`);
149
+ }
150
+ }
151
+ } finally {
152
+ rmSync(root, { recursive: true, force: true });
153
+ }
154
+
155
+ // LITERAL census (never the constant the handler itself exports — that was tinybird's
156
+ // two-constants false-green): the resource types this twin is expected to project.
157
+ for (const type of ['app', 'machine', 'volume', 'secret']) {
158
+ if (!snapshot.resourceTypes.includes(type)) violations.push(`resource type '${type}' missing from snapshot`);
159
+ }
160
+
161
+ return {
162
+ ok: violations.length === 0,
163
+ endpointsChecked: snapshot.implementedEndpoints.length,
164
+ endpointsProbed: probed,
165
+ resourceTypesChecked: snapshot.resourceTypes.length,
166
+ violations,
167
+ };
168
+ }
@@ -0,0 +1,278 @@
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 type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
17
+ import { FlyBudget, FlyBudgetError, flyCallWeight, type FlyBudgetOptions } from './fly-budget.ts';
18
+
19
+ const SERVICE = 'fly';
20
+
21
+ /**
22
+ * The injected real-Fly boundary. One Machines API call:
23
+ * method — 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
24
+ * path — e.g. '/v1/apps?org_slug=personal' or '/v1/apps/my-app/machines'
25
+ * Returns the HTTP status + parsed JSON body. Status-bearing BY DESIGN: a refused/failed pull
26
+ * must be distinguishable from a genuinely empty account (ADDING_A_TWIN §6 — "a REFUSED pull is
27
+ * NOT an empty account"), so every pull helper below THROWS on a non-2xx instead of folding an
28
+ * empty list over real observed state.
29
+ */
30
+ export type FlyExecute = (
31
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
32
+ path: string,
33
+ body?: Record<string, unknown>,
34
+ ) => Promise<{ status: number; body: any }>;
35
+
36
+ /** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
37
+ export type LiveFlyOptions = {
38
+ /** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
39
+ fetchImpl?: typeof fetch;
40
+ /** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
41
+ budget?: FlyBudget;
42
+ /** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
43
+ budgetOptions?: FlyBudgetOptions;
44
+ };
45
+
46
+ /**
47
+ * A live executor against real Fly (token = a Fly.io API token, `fly tokens deploy` / org token).
48
+ * Never imported by the pack's own runtime path — only constructed by a caller opting into real
49
+ * I/O. THIS IS THE ONE PLACE this pack issues a live api.machines.dev request, and the Machines
50
+ * API is a particularly bad place to burst: its writes PROVISION AND BILL real compute. EVERY
51
+ * call is guarded: the budget is charged BEFORE the request goes out (`checkBudget` THROWS
52
+ * `FlyBudgetError` instead of returning when the ceiling or a persisted cooldown says stop) and
53
+ * the response is fed back (`recordCall`) so a `Retry-After`/429 becomes a persisted cooldown
54
+ * that makes every later call fail fast WITHOUT touching Fly. There is deliberately no option
55
+ * that disables the guard; an injected `budget` must be an UNMODIFIED FlyBudget
56
+ * (`assertBudgetGuardIntact` — method identity, not `instanceof`; a subclass overriding
57
+ * `checkBudget` or a Proxy trapping it is refused).
58
+ */
59
+ export function liveFlyExecute(
60
+ token: string,
61
+ base = 'https://api.machines.dev',
62
+ opts: LiveFlyOptions = {},
63
+ ): FlyExecute {
64
+ const doFetch = opts.fetchImpl ?? fetch;
65
+ // The default ledger is keyed by a hash of THIS token — Fly scopes limits under the account
66
+ // the token names, so a cwd-scoped ledger would hand the same token a fresh allowance per
67
+ // checkout/worktree/CI leg.
68
+ const budget = opts.budget !== undefined && opts.budget !== null
69
+ ? assertBudgetGuardIntact(opts.budget, FlyBudget, 'liveFlyExecute')
70
+ : new FlyBudget({ token, ...(opts.budgetOptions ?? {}) });
71
+ return async (method, path, reqBody) => {
72
+ const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
73
+ const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
74
+ if (method !== 'GET' && reqBody) {
75
+ headers['Content-Type'] = 'application/json';
76
+ init.body = JSON.stringify(reqBody);
77
+ }
78
+ const weight = flyCallWeight(method, path.split('?')[0] ?? path);
79
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
80
+ const reservation = budget.checkBudget(weight);
81
+ const res = await doFetch(`${base}${path}`, init);
82
+ const resHeaders: Record<string, string> = {};
83
+ res.headers.forEach((v: string, k: string) => { resHeaders[k.toLowerCase()] = v; });
84
+ const text = await res.text();
85
+ // Settles the reservation and, on a back-off signal, arms the persisted cooldown (may itself
86
+ // throw a louder refusal — the cooldown is persisted first either way).
87
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
88
+ // call that louder refusal wins; an answer Fly ACCEPTED is kept, so a write that landed is
89
+ // never recorded as failed and performed again on retry.
90
+ try {
91
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
92
+ } catch (error) {
93
+ if (!(error instanceof FlyBudgetError) || !res.ok) throw error;
94
+ }
95
+ let body: unknown = {};
96
+ if (text) { try { body = JSON.parse(text); } catch { body = { error: text }; } }
97
+ return { status: res.status, body };
98
+ };
99
+ }
100
+
101
+ /** A non-2xx pull reply is a REFUSAL, never an empty account — throw, don't fold. */
102
+ function refuse(what: string, res: { status: number; body: any }): never {
103
+ const msg = res.body && typeof res.body === 'object' && typeof res.body.error === 'string' ? res.body.error : JSON.stringify(res.body);
104
+ throw new Error(`fly pull ${what} refused: HTTP ${res.status} ${msg}`);
105
+ }
106
+
107
+ // ── Mapping (real Fly → twin SyncResource) — pure, no client (the mutation-test connector
108
+ // sweep leaves map* real by convention) ───────────────────────────────────────────────────────
109
+ export function mapApp(orgSlug: string, a: Record<string, unknown>): SyncResource {
110
+ return {
111
+ type: 'app',
112
+ id: String(a.name ?? a.id),
113
+ fields: {
114
+ org_slug: orgSlug,
115
+ org_name: (a.organization as Record<string, unknown> | undefined)?.name ?? orgSlug,
116
+ network: a.network ?? 'default',
117
+ internal_numeric_id: a.internal_numeric_id ?? null,
118
+ status: a.status ?? 'pending',
119
+ },
120
+ };
121
+ }
122
+
123
+ export function mapMachine(appName: string, m: Record<string, unknown>): SyncResource {
124
+ return {
125
+ type: 'machine',
126
+ id: String(m.id),
127
+ fields: {
128
+ app_name: appName,
129
+ name: m.name ?? '',
130
+ region: m.region ?? '',
131
+ state: m.state ?? 'stopped',
132
+ instance_id: m.instance_id ?? '',
133
+ private_ip: m.private_ip ?? '',
134
+ created_at: m.created_at ?? null,
135
+ machine_updated_at: m.updated_at ?? null,
136
+ config: m.config ?? {},
137
+ image_ref: m.image_ref ?? {},
138
+ events: Array.isArray(m.events) ? [...(m.events as unknown[])].reverse() : [], // the wire is newest-first; the row is in order
139
+ cordoned: m.cordoned === true,
140
+ // A pulled machine runs on REAL Fly, not on any local execution plane: the runtime kind records
141
+ // the fact honestly. The twin's own bookkeeping (`_`-prefixed) is outside the vendor's shape.
142
+ _twin_runtime: 'real-fly',
143
+ },
144
+ };
145
+ }
146
+
147
+ export function mapVolume(appName: string, v: Record<string, unknown>): SyncResource {
148
+ return {
149
+ type: 'volume',
150
+ id: String(v.id),
151
+ fields: {
152
+ app_name: appName,
153
+ name: v.name ?? '',
154
+ region: v.region ?? '',
155
+ size_gb: v.size_gb ?? 1,
156
+ encrypted: v.encrypted !== false,
157
+ fstype: v.fstype ?? 'ext4',
158
+ snapshot_retention: v.snapshot_retention ?? 5,
159
+ auto_backup_enabled: v.auto_backup_enabled !== false,
160
+ zone: v.zone ?? '',
161
+ state: v.state ?? 'created',
162
+ attached_machine_id: (v.attached_machine_id as string | null) ?? null,
163
+ created_at: v.created_at ?? null,
164
+ snapshots: [],
165
+ },
166
+ };
167
+ }
168
+
169
+ // ── PULL ──────────────────────────────────────────────────────────────────────────────────────
170
+ export async function pullFlyApps(execute: FlyExecute, orgSlug: string): Promise<SyncResource[]> {
171
+ const res = await execute('GET', `/v1/apps?org_slug=${encodeURIComponent(orgSlug)}`);
172
+ if (res.status < 200 || res.status >= 300) refuse('apps', res);
173
+ const apps = Array.isArray(res.body?.apps) ? (res.body.apps as Record<string, unknown>[]) : [];
174
+ // the list is the short shape (id, name, machine_count, network, status); the App itself — its internal
175
+ // numeric id, its organization — answers at /v1/apps/{name}, one read per app
176
+ const out: SyncResource[] = [];
177
+ for (const a of apps) {
178
+ const name = String(a.name ?? a.id);
179
+ const detail = await execute('GET', `/v1/apps/${encodeURIComponent(name)}`);
180
+ if (detail.status < 200 || detail.status >= 300) refuse(`app ${name}`, detail);
181
+ out.push(mapApp(orgSlug, { ...a, ...(detail.body as Record<string, unknown>) }));
182
+ }
183
+ return out;
184
+ }
185
+
186
+ export async function pullFlyMachines(execute: FlyExecute, appName: string): Promise<SyncResource[]> {
187
+ const res = await execute('GET', `/v1/apps/${encodeURIComponent(appName)}/machines`);
188
+ if (res.status < 200 || res.status >= 300) refuse(`machines of ${appName}`, res);
189
+ const machines = Array.isArray(res.body) ? (res.body as Record<string, unknown>[]) : [];
190
+ return machines.map((m) => mapMachine(appName, m));
191
+ }
192
+
193
+ export async function pullFlyVolumes(execute: FlyExecute, appName: string): Promise<SyncResource[]> {
194
+ const res = await execute('GET', `/v1/apps/${encodeURIComponent(appName)}/volumes`);
195
+ if (res.status < 200 || res.status >= 300) refuse(`volumes of ${appName}`, res);
196
+ const volumes = Array.isArray(res.body) ? (res.body as Record<string, unknown>[]) : [];
197
+ return volumes.map((v) => mapVolume(appName, v));
198
+ }
199
+
200
+ /**
201
+ * D7 entry point: pull the real account's apps (and each app's machines + volumes) and fold them
202
+ * into the twin via ONE `observeResources` batch (the fold's own diff). Returns `{observed, deltasAppended}` — a
203
+ * re-pull of identical state appends ZERO deltas. `occurredAt` defaults to a MOVING now (never a
204
+ * pinned constant — a pinned pull default makes a reverting vendor value collide with its own
205
+ * earlier observation and vanish as a phantom delta; ADDING_A_TWIN §6).
206
+ */
207
+ export async function syncFlyFromReal(
208
+ execute: FlyExecute,
209
+ opts: { root?: string; occurredAt?: string; orgSlug?: string } = {},
210
+ ): Promise<{ observed: number; deltasAppended: number }> {
211
+ const orgSlug = opts.orgSlug ?? 'personal';
212
+ const occurredAt = opts.occurredAt ?? new Date().toISOString();
213
+ const apps = await pullFlyApps(execute, orgSlug);
214
+ const nested: SyncResource[] = [];
215
+ for (const app of apps) {
216
+ const appName = app.id;
217
+ const [machines, volumes] = await Promise.all([
218
+ pullFlyMachines(execute, appName),
219
+ pullFlyVolumes(execute, appName),
220
+ ]);
221
+ nested.push(...machines, ...volumes);
222
+ }
223
+ const resources = [...apps, ...nested];
224
+ // protocol 2: an observation lands on the head through the kernel's fold — one batch, one instant
225
+ 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}` });
226
+ return { observed: report.observed, deltasAppended: report.appended };
227
+ }
228
+
229
+ // ── PROTOCOL 2: the state system's two adapters, over the kernel's executor ──────────
230
+ /** The pack's executor over the kernel's: the same Machines API call, carried by the head. */
231
+ export function flyExecuteOver(execute: RemoteExecute): FlyExecute {
232
+ return async (method, path, body) => {
233
+ // the wire wants a bearer: at a real boundary the kernel's executor sets the sealed credential over this
234
+ // header (executor.ts — a pack never holds one); at the twin's own wire, refreshed from itself, any bearer is a bearer
235
+ const res = await execute({ method, path, headers: { accept: 'application/json', authorization: 'Bearer twin', ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
236
+ let parsed: unknown = {};
237
+ if (res.body) { try { parsed = JSON.parse(res.body); } catch { parsed = { error: res.body }; } }
238
+ return { status: res.status, body: parsed };
239
+ };
240
+ }
241
+ /** The refresh adapter: pull the org's apps, machines and volumes through the executor and fold them into the root. */
242
+ export async function syncFlyFromRemote(execute: RemoteExecute, opts: { root?: string; origin?: string; occurredAt?: string; orgSlug?: string } = {}): Promise<{ observed: number; deltasAppended: number }> {
243
+ return syncFlyFromReal(flyExecuteOver(execute), { ...(opts.root !== undefined ? { root: opts.root } : {}), ...(opts.orgSlug !== undefined ? { orgSlug: opts.orgSlug } : {}), occurredAt: opts.occurredAt ?? new Date().toISOString() });
244
+ }
245
+ /** The perform adapter: one entry crosses to Fly through the executor — an app, a volume, a machine and its
246
+ * lifecycle, a secret. An entry that is the twin's own (a lease, a purge, flyd's fold of a container exit, a
247
+ * snapshot) crosses nothing. A referenced id (a mount's volume, a path's machine) is the vendor's by then. */
248
+ export async function performFlyAction(execute: RemoteExecute, action: TwinAction, ctx: PerformContext): Promise<PushOutcome> {
249
+ const fly = flyExecuteOver(execute);
250
+ const op = action.operation ?? `${action.subject.type}.update`;
251
+ const f = action.fields ?? {};
252
+ const app = encodeURIComponent(String(f.app_name ?? ''));
253
+ const answer = async (method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, body?: Record<string, unknown>): Promise<Record<string, unknown>> => {
254
+ const res = await fly(method, path, body);
255
+ if (res.status < 200 || res.status >= 300) throw new Error(`fly perform ${op} refused: HTTP ${res.status} ${JSON.stringify(res.body).slice(0, 200)}`);
256
+ return (res.body ?? {}) as Record<string, unknown>;
257
+ };
258
+ const machineId = () => encodeURIComponent(ctx.resolve('machine', action.subject.id));
259
+ const volumeId = () => encodeURIComponent(ctx.resolve('volume', action.subject.id));
260
+ switch (op) {
261
+ case 'app.create': await answer('POST', '/v1/apps', { app_name: action.subject.id, org_slug: f.org_slug ?? 'personal', network: f.network ?? 'default' }); return { externalId: action.subject.id };
262
+ case 'app.delete': await answer('DELETE', `/v1/apps/${encodeURIComponent(action.subject.id)}`); return { externalId: action.subject.id };
263
+ case 'volume.create': { 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 }); return { externalId: String(v.id ?? action.subject.id), data: v }; }
264
+ case 'volume.delete': await answer('DELETE', `/v1/apps/${app}/volumes/${volumeId()}`); return { externalId: ctx.resolve('volume', action.subject.id) };
265
+ case 'machine.create': {
266
+ const config = { ...((f.config ?? {}) as Record<string, unknown>) };
267
+ if (Array.isArray(config.mounts)) config.mounts = (config.mounts as Array<Record<string, unknown>>).map((m) => (typeof m.volume === 'string' ? { ...m, volume: ctx.resolve('volume', m.volume) } : m));
268
+ const m = await answer('POST', `/v1/apps/${app}/machines`, { name: f.name, region: f.region, config, ...(f.state === 'created' ? { skip_launch: true } : {}) });
269
+ return { externalId: String(m.id ?? action.subject.id), data: m };
270
+ }
271
+ case 'machine.update': await answer('POST', `/v1/apps/${app}/machines/${machineId()}`, { config: f.config }); return { externalId: ctx.resolve('machine', action.subject.id) };
272
+ case 'machine.start': case 'machine.stop': case 'machine.suspend': case 'machine.restart': case 'machine.cordon': case 'machine.uncordon':
273
+ await answer('POST', `/v1/apps/${app}/machines/${machineId()}/${op.slice('machine.'.length)}`); return { externalId: ctx.resolve('machine', action.subject.id) };
274
+ case 'machine.destroy': await answer('DELETE', `/v1/apps/${app}/machines/${machineId()}?force=true`); return { externalId: ctx.resolve('machine', action.subject.id) };
275
+ case 'secret.set': await answer('POST', `/v1/apps/${app}/secrets`, { values: { [String(f.secret_name)]: f.value } }); return { externalId: action.subject.id };
276
+ default: return { externalId: action.subject.id, data: { performed: false, reason: `${op} is the twin's own record — nothing at Fly to write` } };
277
+ }
278
+ }