deepline 0.3.90 → 0.3.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +101 -6
  2. package/dist/bundling-sources/sdk/src/monitor-fleet-contract.ts +2 -556
  3. package/dist/bundling-sources/sdk/src/monitor-fleets.ts +2 -2
  4. package/dist/bundling-sources/sdk/src/release.ts +7 -12
  5. package/dist/bundling-sources/sdk/src/types.ts +6 -1
  6. package/dist/bundling-sources/shared_libs/integration-contracts/monitor-fleet-contract.ts +549 -0
  7. package/dist/bundling-sources/shared_libs/monitors/fleets/errors.ts +96 -0
  8. package/dist/bundling-sources/shared_libs/monitors/fleets/limits.ts +8 -0
  9. package/dist/bundling-sources/shared_libs/monitors/fleets/plan.ts +320 -0
  10. package/dist/bundling-sources/shared_libs/monitors/fleets/verdict.ts +106 -0
  11. package/dist/bundling-sources/shared_libs/monitors/http-error-response.ts +5 -1
  12. package/dist/bundling-sources/shared_libs/monitors/monitor-fleet-limits.ts +2 -12
  13. package/dist/bundling-sources/shared_libs/monitors/package.json +107 -5
  14. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-batch-operation.ts +12 -7
  15. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +36 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +1 -1
  17. package/dist/bundling-sources/shared_libs/play-runtime/db-session-lease-store.ts +578 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-execution-relay.ts +521 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-receipt-gateway.ts +1378 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/runtime-topology.ts +1024 -0
  21. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-gateway-route-probe.ts +193 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machine-lifecycle.ts +104 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machines.ts +1844 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +21 -0
  25. package/dist/bundling-sources/shared_libs/play-runtime/deployment/process-tree.ts +40 -0
  26. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-gateway-drain.ts +262 -0
  27. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-surface.ts +98 -0
  28. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-topology.ts +303 -0
  29. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-publication-guardian.ts +70 -0
  30. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-recovery.ts +519 -0
  31. package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-fly-rollback-alignment.ts +280 -0
  32. package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-production-gate.ts +159 -0
  33. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/memory-store.ts +114 -0
  34. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +44 -5
  35. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/child-play-resolver.ts +26 -0
  36. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +3662 -0
  37. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/progress-sidecar.ts +47 -0
  38. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-deadline.ts +84 -0
  39. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-gateway-transport.ts +91 -0
  40. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-heartbeat.ts +424 -0
  41. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-progress-push.ts +321 -0
  42. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-sheet-persistence-retry.ts +110 -0
  43. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-terminal-push.ts +625 -0
  44. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +46 -3
  45. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-gateway.ts +1 -1
  46. package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +50 -0
  47. package/dist/bundling-sources/shared_libs/plays/bundling/repository-adapter.ts +24 -0
  48. package/dist/bundling-sources/shared_libs/plays/child-run-projection.ts +174 -0
  49. package/dist/bundling-sources/shared_libs/plays/deploy/api-client.ts +267 -0
  50. package/dist/bundling-sources/shared_libs/plays/deploy/control-plane-types.ts +73 -0
  51. package/dist/bundling-sources/shared_libs/plays/deploy/manifest.ts +162 -0
  52. package/dist/bundling-sources/shared_libs/plays/deploy/project-serializer.ts +87 -0
  53. package/dist/bundling-sources/shared_libs/plays/deploy/source-file-path.ts +26 -0
  54. package/dist/bundling-sources/shared_libs/plays/deploy/target-paths.ts +36 -0
  55. package/dist/bundling-sources/shared_libs/plays/launch/input-contract.ts +400 -0
  56. package/dist/bundling-sources/shared_libs/plays/launch/prepare.ts +96 -0
  57. package/dist/bundling-sources/shared_libs/plays/local-file-discovery.ts +385 -0
  58. package/dist/bundling-sources/shared_libs/plays/play-grep-search.ts +79 -6
  59. package/dist/bundling-sources/shared_libs/plays/play-reference.ts +117 -0
  60. package/dist/bundling-sources/shared_libs/plays/run-input-summary.ts +260 -0
  61. package/dist/bundling-sources/shared_libs/plays/run-summary-types.ts +49 -0
  62. package/dist/bundling-sources/shared_libs/plays/secrets/errors.ts +19 -0
  63. package/dist/bundling-sources/shared_libs/plays/sheet-filter.ts +347 -0
  64. package/dist/bundling-sources/shared_libs/plays/standard-webhooks.ts +117 -0
  65. package/dist/bundling-sources/shared_libs/plays/status-result.ts +388 -0
  66. package/dist/bundling-sources/shared_libs/plays/transform-factor-structure.ts +377 -0
  67. package/dist/bundling-sources/shared_libs/plays/webhook-payload.ts +113 -0
  68. package/dist/cli/index.js +369 -162
  69. package/dist/cli/index.mjs +369 -162
  70. package/dist/{compiler-manifest-BaJfE-5H.d.mts → compiler-manifest-x14KL83E.d.mts} +1 -1
  71. package/dist/{compiler-manifest-BaJfE-5H.d.ts → compiler-manifest-x14KL83E.d.ts} +1 -1
  72. package/dist/index.d.mts +45 -8
  73. package/dist/index.d.ts +45 -8
  74. package/dist/index.js +61 -14
  75. package/dist/index.mjs +61 -14
  76. package/dist/install-integrity.json +54 -2
  77. package/dist/monitor-fleet-contract.d.mts +3 -10
  78. package/dist/monitor-fleet-contract.d.ts +3 -10
  79. package/dist/monitor-fleet-contract.js +2 -0
  80. package/dist/monitor-fleet-contract.mjs +1 -1
  81. package/dist/plays/bundle-play-file.d.mts +2 -2
  82. package/dist/plays/bundle-play-file.d.ts +2 -2
  83. package/dist/release.d.mts +3 -3
  84. package/dist/release.d.ts +3 -3
  85. package/dist/release.js +7 -12
  86. package/dist/release.mjs +7 -12
  87. package/package.json +2 -2
@@ -0,0 +1,1024 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Typed, declarative release executor for the fixed production Fly runtime.
5
+ *
6
+ * The manifest owns identities and machine shapes. This command owns the one
7
+ * fenced transition between receipt gateway revisions. GitHub Actions passes
8
+ * credentials and the immutable release SHA; it does not encode the state
9
+ * machine in YAML.
10
+ */
11
+ import {
12
+ spawn,
13
+ spawnSync,
14
+ type SpawnSyncOptionsWithStringEncoding,
15
+ type SpawnSyncReturns,
16
+ } from 'node:child_process';
17
+ import {
18
+ appendFileSync,
19
+ renameSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ } from 'node:fs';
23
+ import { productionGatewayTopology } from '../../deployment/runtime-topology';
24
+ import {
25
+ shouldCreateDetachedProcessGroup,
26
+ terminateChildProcessTree,
27
+ } from '../process-tree';
28
+
29
+ export enum TopologyOperation {
30
+ ReconcilePlatform = 'reconcile-platform',
31
+ StageGatewaySecrets = 'stage-gateway-secrets',
32
+ EnsureExecutionRelay = 'ensure-execution-relay',
33
+ PrepareReceiptCandidates = 'prepare-receipt-candidates',
34
+ ProveReceiptCandidates = 'prove-receipt-candidates',
35
+ ActivateReceiptCandidates = 'activate-receipt-candidates',
36
+ ProveActiveGatewayFleet = 'prove-active-gateway-fleet',
37
+ CleanupReceiptCandidates = 'cleanup-receipt-candidates',
38
+ }
39
+
40
+ /**
41
+ * The only release transitions are prepare → prove → commit. Candidate
42
+ * identities live in Fly metadata and the typed phase prevents an in-process
43
+ * caller from activating an unproven topology.
44
+ */
45
+ export enum ProductionTopologyControllerPhase {
46
+ Prepare = 'prepare',
47
+ Prove = 'prove',
48
+ Commit = 'commit',
49
+ }
50
+
51
+ enum RuntimeTopologyCliCommand {
52
+ Prepare = 'prepare',
53
+ Prove = 'prove',
54
+ Commit = 'commit',
55
+ Rollback = 'rollback',
56
+ Cleanup = 'cleanup',
57
+ }
58
+
59
+ export type ProductionTopologyPlan = {
60
+ environment: 'production';
61
+ releaseSha: string;
62
+ operations: readonly TopologyOperation[];
63
+ receiptGatewayApp: string;
64
+ executionRelayApp: string;
65
+ };
66
+
67
+ const operations: readonly TopologyOperation[] = [
68
+ TopologyOperation.ReconcilePlatform,
69
+ TopologyOperation.StageGatewaySecrets,
70
+ TopologyOperation.EnsureExecutionRelay,
71
+ TopologyOperation.PrepareReceiptCandidates,
72
+ TopologyOperation.ProveReceiptCandidates,
73
+ TopologyOperation.ActivateReceiptCandidates,
74
+ TopologyOperation.ProveActiveGatewayFleet,
75
+ ];
76
+
77
+ export function planProductionTopologyRelease(input: {
78
+ releaseSha: string;
79
+ }): ProductionTopologyPlan {
80
+ if (!/^[a-f0-9]{40}$/i.test(input.releaseSha)) {
81
+ throw new Error('The topology plan requires a full 40-character git SHA.');
82
+ }
83
+ return {
84
+ environment: 'production',
85
+ releaseSha: input.releaseSha.toLowerCase(),
86
+ operations,
87
+ receiptGatewayApp: productionGatewayTopology('receipt').app,
88
+ executionRelayApp: productionGatewayTopology('execution').app,
89
+ };
90
+ }
91
+
92
+ export function planProductionTopologyCleanup(input: {
93
+ releaseSha: string;
94
+ }): ProductionTopologyPlan {
95
+ const release = planProductionTopologyRelease(input);
96
+ return {
97
+ ...release,
98
+ operations: [TopologyOperation.CleanupReceiptCandidates],
99
+ };
100
+ }
101
+
102
+ export type RuntimeTopologyCommand = (input: {
103
+ command: string;
104
+ args: string[];
105
+ environment: Record<string, string>;
106
+ description: string;
107
+ }) => void | Promise<void>;
108
+
109
+ /**
110
+ * Candidate preparation for the two gateway apps is independent once their
111
+ * shared secrets have been staged. The asynchronous form lets the production
112
+ * CLI boot them together, while the synchronous command remains useful for
113
+ * deterministic unit tests and one-at-a-time recovery paths.
114
+ */
115
+ export type RuntimeTopologyParallelCommand = (input: {
116
+ command: string;
117
+ args: string[];
118
+ environment: Record<string, string>;
119
+ description: string;
120
+ }) => Promise<void>;
121
+
122
+ export type RuntimeTopologySpawn = (
123
+ command: string,
124
+ args: readonly string[],
125
+ options: SpawnSyncOptionsWithStringEncoding,
126
+ ) => Pick<SpawnSyncReturns<string>, 'error' | 'signal' | 'status'>;
127
+
128
+ type RuntimeTopologyCancellationSignal = 'SIGINT' | 'SIGTERM';
129
+ type RuntimeTopologySignalProcess = {
130
+ on: (
131
+ signal: RuntimeTopologyCancellationSignal,
132
+ listener: () => void,
133
+ ) => unknown;
134
+ removeListener: (
135
+ signal: RuntimeTopologyCancellationSignal,
136
+ listener: () => void,
137
+ ) => unknown;
138
+ };
139
+ type ActiveRuntimeTopologyCommand = Readonly<{
140
+ cancel: (signal: RuntimeTopologyCancellationSignal) => void;
141
+ }>;
142
+
143
+ const activeRuntimeTopologyCommands = new Set<ActiveRuntimeTopologyCommand>();
144
+ const runtimeTopologyCancellationSignals: readonly RuntimeTopologyCancellationSignal[] =
145
+ ['SIGINT', 'SIGTERM'];
146
+
147
+ function cancelActiveRuntimeTopologyCommands(
148
+ signal: RuntimeTopologyCancellationSignal,
149
+ ): number {
150
+ for (const command of activeRuntimeTopologyCommands) command.cancel(signal);
151
+ return activeRuntimeTopologyCommands.size;
152
+ }
153
+
154
+ /**
155
+ * `prepare` owns two parallel gateway child processes. Propagate GitHub's
156
+ * cancellation signal to both so the parent can compensate their cordoned
157
+ * candidate state instead of leaving an orphaned health wait behind.
158
+ */
159
+ export function installRuntimeTopologyCancellationHandlers(
160
+ input: {
161
+ signalProcess?: RuntimeTopologySignalProcess;
162
+ cancelActiveCommands?: (
163
+ signal: RuntimeTopologyCancellationSignal,
164
+ ) => number;
165
+ reportCancellation?: (message: string) => void;
166
+ } = {},
167
+ ): () => void {
168
+ const signalProcess = input.signalProcess ?? process;
169
+ const cancelActiveCommands =
170
+ input.cancelActiveCommands ?? cancelActiveRuntimeTopologyCommands;
171
+ const reportCancellation = input.reportCancellation ?? console.error;
172
+ const listeners = new Map<RuntimeTopologyCancellationSignal, () => void>();
173
+ for (const signal of runtimeTopologyCancellationSignals) {
174
+ const listener = () => {
175
+ const active = cancelActiveCommands(signal);
176
+ reportCancellation(
177
+ `[runtime-topology] cancellation requested by ${signal}; stopping ${active} active topology command(s) before compensation.`,
178
+ );
179
+ };
180
+ listeners.set(signal, listener);
181
+ signalProcess.on(signal, listener);
182
+ }
183
+ return () => {
184
+ for (const [signal, listener] of listeners) {
185
+ signalProcess.removeListener(signal, listener);
186
+ }
187
+ };
188
+ }
189
+
190
+ type TopologyExecutionInput = {
191
+ plan: ProductionTopologyPlan;
192
+ environment: Record<string, string>;
193
+ command: RuntimeTopologyCommand;
194
+ parallelCommand?: RuntimeTopologyParallelCommand;
195
+ };
196
+
197
+ export type PreparedProductionTopology = Readonly<{
198
+ phase: ProductionTopologyControllerPhase.Prepare;
199
+ plan: ProductionTopologyPlan;
200
+ }>;
201
+
202
+ export type ProvenProductionTopology = Readonly<{
203
+ phase: ProductionTopologyControllerPhase.Prove;
204
+ plan: ProductionTopologyPlan;
205
+ stagedVercelUrl: string;
206
+ }>;
207
+
208
+ /**
209
+ * The workflow-scoped proof receipt joins two separate shell steps without
210
+ * weakening the typed prepare → prove → commit boundary. It contains no
211
+ * credentials and is intentionally retained after commit: an interrupted
212
+ * commit must resume its exact SHA-fenced provider operation, not invent a
213
+ * replacement topology.
214
+ */
215
+ type ProvenTopologyReceipt = Readonly<{
216
+ phase: ProductionTopologyControllerPhase.Prove;
217
+ releaseSha: string;
218
+ stagedVercelUrl: string;
219
+ }>;
220
+
221
+ export type CommittedProductionTopology = Readonly<{
222
+ phase: ProductionTopologyControllerPhase.Commit;
223
+ plan: ProductionTopologyPlan;
224
+ stagedVercelUrl: string;
225
+ }>;
226
+
227
+ export function runRuntimeTopologyCommand(
228
+ input: Parameters<RuntimeTopologyCommand>[0],
229
+ command: RuntimeTopologySpawn = spawnSync,
230
+ ): void {
231
+ const result = command(input.command, input.args, {
232
+ encoding: 'utf8',
233
+ stdio: 'inherit',
234
+ env: { ...process.env, ...input.environment },
235
+ });
236
+ if (result.error) {
237
+ throw new Error(
238
+ `${input.description} could not start: ${result.error.message}`,
239
+ { cause: result.error },
240
+ );
241
+ }
242
+ if (result.status !== 0) {
243
+ // Secret staging passes values as Fly CLI args. Do not echo args on error.
244
+ throw new Error(
245
+ result.signal
246
+ ? `${input.description} was terminated by ${result.signal}.`
247
+ : `${input.description} failed with exit code ${result.status}.`,
248
+ );
249
+ }
250
+ }
251
+
252
+ export async function runRuntimeTopologyAsyncCommand(input: {
253
+ command: string;
254
+ args: string[];
255
+ environment: Record<string, string>;
256
+ description: string;
257
+ }): Promise<void> {
258
+ await new Promise<void>((resolve, reject) => {
259
+ const child = spawn(input.command, input.args, {
260
+ env: { ...process.env, ...input.environment },
261
+ stdio: 'inherit',
262
+ detached: shouldCreateDetachedProcessGroup(),
263
+ });
264
+ const activeCommand: ActiveRuntimeTopologyCommand = {
265
+ cancel: (signal) => {
266
+ if (child.exitCode === null && child.signalCode === null) {
267
+ try {
268
+ terminateChildProcessTree(child, signal);
269
+ } catch (error) {
270
+ console.error(
271
+ `[runtime-topology] could not stop ${input.description} process tree: ${error instanceof Error ? error.message : String(error)}`,
272
+ );
273
+ }
274
+ }
275
+ },
276
+ };
277
+ activeRuntimeTopologyCommands.add(activeCommand);
278
+ let settled = false;
279
+ const settle = (): boolean => {
280
+ if (settled) return false;
281
+ settled = true;
282
+ activeRuntimeTopologyCommands.delete(activeCommand);
283
+ return true;
284
+ };
285
+ child.once('error', (error) => {
286
+ if (!settle()) return;
287
+ reject(
288
+ new Error(`${input.description} could not start: ${error.message}`, {
289
+ cause: error,
290
+ }),
291
+ );
292
+ });
293
+ child.once('close', (status, signal) => {
294
+ if (!settle()) return;
295
+ if (status === 0) {
296
+ resolve();
297
+ return;
298
+ }
299
+ reject(
300
+ new Error(
301
+ signal
302
+ ? `${input.description} was terminated by ${signal}.`
303
+ : `${input.description} failed with exit code ${status ?? '<unknown>'}.`,
304
+ ),
305
+ );
306
+ });
307
+ });
308
+ }
309
+
310
+ const runCommand: RuntimeTopologyCommand = runRuntimeTopologyAsyncCommand;
311
+
312
+ export async function runRuntimeTopologyParallelCommand(input: {
313
+ command: string;
314
+ args: string[];
315
+ environment: Record<string, string>;
316
+ description: string;
317
+ }): Promise<void> {
318
+ const startedAt = Date.now();
319
+ console.info(`[runtime-topology] ▶ ${input.description}`);
320
+ await runRuntimeTopologyAsyncCommand(input);
321
+ console.info(
322
+ `[runtime-topology] ✓ ${input.description} (${Math.max(0, Math.round((Date.now() - startedAt) / 1000))}s)`,
323
+ );
324
+ }
325
+
326
+ async function runIndependentTopologyCommands(
327
+ input: TopologyExecutionInput,
328
+ commands: readonly Parameters<RuntimeTopologyCommand>[0][],
329
+ ): Promise<void> {
330
+ const run =
331
+ input.parallelCommand ??
332
+ (async (command: Parameters<RuntimeTopologyCommand>[0]) => {
333
+ await input.command(command);
334
+ });
335
+ const results = await Promise.allSettled(
336
+ commands.map((command) => run(command)),
337
+ );
338
+ const failures = results
339
+ .filter(
340
+ (result): result is PromiseRejectedResult => result.status === 'rejected',
341
+ )
342
+ .map((result) => result.reason);
343
+ if (failures.length === 1) throw failures[0];
344
+ if (failures.length > 1) {
345
+ throw new AggregateError(
346
+ failures,
347
+ 'Independent gateway preparation operations failed.',
348
+ );
349
+ }
350
+ }
351
+
352
+ async function stageSecrets(input: {
353
+ plan: ProductionTopologyPlan;
354
+ environment: Record<string, string>;
355
+ command: RuntimeTopologyCommand;
356
+ parallelCommand?: RuntimeTopologyParallelCommand;
357
+ }): Promise<void> {
358
+ const commands = [
359
+ input.plan.receiptGatewayApp,
360
+ input.plan.executionRelayApp,
361
+ ].map((app) => ({
362
+ command: 'flyctl',
363
+ args: [
364
+ 'secrets',
365
+ 'set',
366
+ '--app',
367
+ app,
368
+ '--stage',
369
+ `WORKFLOW_RUNTIME_POSTGRES=${input.environment.WORKFLOW_RUNTIME_POSTGRES}`,
370
+ `DEEPLINE_INTERNAL_TOKEN=${input.environment.DEEPLINE_INTERNAL_TOKEN}`,
371
+ ],
372
+ environment: input.environment,
373
+ description: `Stage secrets for ${app}`,
374
+ }));
375
+ await runIndependentTopologyCommands(input, commands);
376
+ }
377
+
378
+ /**
379
+ * Refuse ambiguous destinations before touching receipt routing. The Vercel
380
+ * gate resolves this URL from Vercel's deployment record; `commit` accepts it
381
+ * as an explicit immutable input so a later step never waits or discovers a
382
+ * different release on its own.
383
+ */
384
+ export function assertStagedVercelUrl(stagedVercelUrl: string): string {
385
+ if (/[\r\n]/.test(stagedVercelUrl)) {
386
+ throw new Error('--staged-url must be an absolute HTTPS URL.');
387
+ }
388
+ let parsed: URL;
389
+ try {
390
+ parsed = new URL(stagedVercelUrl);
391
+ } catch {
392
+ throw new Error('--staged-url must be an absolute HTTPS URL.');
393
+ }
394
+ if (
395
+ parsed.protocol !== 'https:' ||
396
+ !parsed.hostname ||
397
+ parsed.username ||
398
+ parsed.password ||
399
+ parsed.hash ||
400
+ parsed.search
401
+ ) {
402
+ throw new Error('--staged-url must be an absolute HTTPS URL.');
403
+ }
404
+ return stagedVercelUrl;
405
+ }
406
+
407
+ export async function cleanupProductionGatewayCandidates(input: {
408
+ plan: ProductionTopologyPlan;
409
+ environment: Record<string, string>;
410
+ command: RuntimeTopologyCommand;
411
+ description?: string;
412
+ }): Promise<void> {
413
+ await input.command({
414
+ command: 'bun',
415
+ args: [
416
+ 'scripts/roll-receipt-gateway.ts',
417
+ 'cleanup',
418
+ '--environment',
419
+ 'production',
420
+ '--sha',
421
+ input.plan.releaseSha,
422
+ '--apply',
423
+ ],
424
+ environment: {
425
+ ...input.environment,
426
+ GITHUB_SHA: input.plan.releaseSha,
427
+ },
428
+ description:
429
+ input.description ?? 'Clean up still-cordoned receipt gateway candidates',
430
+ });
431
+ await input.command({
432
+ command: 'bun',
433
+ args: [
434
+ 'scripts/roll-execution-relay.ts',
435
+ 'cleanup',
436
+ '--environment',
437
+ 'production',
438
+ '--sha',
439
+ input.plan.releaseSha,
440
+ '--apply',
441
+ ],
442
+ environment: {
443
+ ...input.environment,
444
+ GITHUB_SHA: input.plan.releaseSha,
445
+ },
446
+ description: 'Clean up still-cordoned execution relay candidates',
447
+ });
448
+ }
449
+
450
+ function releaseEnvironment(
451
+ input: TopologyExecutionInput,
452
+ ): Record<string, string> {
453
+ // A manual dispatch may name an older immutable main SHA. Every child fence
454
+ // must observe that target rather than the workflow file checkout SHA.
455
+ return {
456
+ ...input.environment,
457
+ GITHUB_SHA: input.plan.releaseSha,
458
+ };
459
+ }
460
+
461
+ function writeGithubStepOutput(name: string, value: string): void {
462
+ if (process.env.GITHUB_OUTPUT) {
463
+ appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
464
+ }
465
+ }
466
+
467
+ async function compensatePreparedGatewayCandidates(input: {
468
+ plan: ProductionTopologyPlan;
469
+ environment: Record<string, string>;
470
+ command: RuntimeTopologyCommand;
471
+ }): Promise<void> {
472
+ try {
473
+ await cleanupProductionGatewayCandidates({
474
+ ...input,
475
+ description: 'Compensate still-cordoned gateway candidates',
476
+ });
477
+ } catch (cleanupError) {
478
+ console.error(
479
+ `[runtime-topology] compensation failed; retaining the fenced candidates: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
480
+ );
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Idempotently create the cordoned Fly topology. This phase deliberately does
486
+ * not wait for Vercel or change receipt traffic; it can run beside the staged
487
+ * Vercel build and is safe to retry after a runner interruption.
488
+ */
489
+ export async function prepareProductionTopologyPlan(
490
+ input: TopologyExecutionInput,
491
+ ): Promise<PreparedProductionTopology> {
492
+ const environment = releaseEnvironment(input);
493
+ let gatewayPrepareStarted = false;
494
+ try {
495
+ await input.command({
496
+ command: 'bun',
497
+ args: ['scripts/reconcile-runtime-topology.ts', 'ensure-production-apps'],
498
+ environment,
499
+ description: 'Reconcile declared Fly apps',
500
+ });
501
+ await stageSecrets({ ...input, environment, command: input.command });
502
+ const executionRelayArgs = [
503
+ 'scripts/roll-execution-relay.ts',
504
+ 'prepare',
505
+ '--environment',
506
+ 'production',
507
+ '--sha',
508
+ input.plan.releaseSha,
509
+ '--apply',
510
+ ];
511
+ // Set the fence immediately before the first provider mutation: an
512
+ // interrupted relay create can leave a partial cordoned candidate, while
513
+ // app/secrets reconciliation has nothing candidate-scoped to clean up.
514
+ gatewayPrepareStarted = true;
515
+ await runIndependentTopologyCommands(input, [
516
+ {
517
+ command: 'bun',
518
+ args: executionRelayArgs,
519
+ environment,
520
+ description: 'Ensure execution relay fleet',
521
+ },
522
+ {
523
+ command: 'bun',
524
+ args: [
525
+ 'scripts/roll-receipt-gateway.ts',
526
+ 'prepare',
527
+ '--environment',
528
+ 'production',
529
+ '--sha',
530
+ input.plan.releaseSha,
531
+ '--apply',
532
+ ],
533
+ environment,
534
+ description: 'Prepare receipt gateway candidates',
535
+ },
536
+ ]);
537
+ return {
538
+ phase: ProductionTopologyControllerPhase.Prepare,
539
+ plan: input.plan,
540
+ };
541
+ } catch (error) {
542
+ if (gatewayPrepareStarted) {
543
+ await compensatePreparedGatewayCandidates({
544
+ plan: input.plan,
545
+ environment,
546
+ command: input.command,
547
+ });
548
+ }
549
+ throw error;
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Prove the exact SHA-fenced candidates through the signed internal fixture
555
+ * before any Fly public-route mutation. A failed proof can safely compensate
556
+ * because the gateway command refuses to uncordon during this phase.
557
+ */
558
+ export async function proveProductionTopologyPlan(input: {
559
+ prepared: PreparedProductionTopology;
560
+ stagedVercelUrl: string;
561
+ fixtureApplicationDirectory?: string;
562
+ environment: Record<string, string>;
563
+ command: RuntimeTopologyCommand;
564
+ }): Promise<ProvenProductionTopology> {
565
+ const stagedVercelUrl = assertStagedVercelUrl(input.stagedVercelUrl);
566
+ const environment = {
567
+ ...input.environment,
568
+ GITHUB_SHA: input.prepared.plan.releaseSha,
569
+ };
570
+ try {
571
+ await input.command({
572
+ command: 'bun',
573
+ args: [
574
+ 'scripts/roll-receipt-gateway.ts',
575
+ 'prove',
576
+ '--environment',
577
+ 'production',
578
+ '--sha',
579
+ input.prepared.plan.releaseSha,
580
+ '--staged-url',
581
+ stagedVercelUrl,
582
+ ...(input.fixtureApplicationDirectory
583
+ ? ['--fixture-application-dir', input.fixtureApplicationDirectory]
584
+ : []),
585
+ '--apply',
586
+ ],
587
+ environment,
588
+ description: 'Prove cordoned receipt gateway candidates',
589
+ });
590
+ return {
591
+ phase: ProductionTopologyControllerPhase.Prove,
592
+ plan: input.prepared.plan,
593
+ stagedVercelUrl,
594
+ };
595
+ } catch (error) {
596
+ await compensatePreparedGatewayCandidates({
597
+ plan: input.prepared.plan,
598
+ environment,
599
+ command: input.command,
600
+ });
601
+ throw error;
602
+ }
603
+ }
604
+
605
+ function topologyProofReceipt(
606
+ input: ProvenProductionTopology,
607
+ ): ProvenTopologyReceipt {
608
+ return {
609
+ phase: ProductionTopologyControllerPhase.Prove,
610
+ releaseSha: input.plan.releaseSha,
611
+ stagedVercelUrl: input.stagedVercelUrl,
612
+ };
613
+ }
614
+
615
+ function proofReceiptPath(path: string): string {
616
+ if (!path || /[\r\n]/.test(path)) {
617
+ throw new Error('--proof-file must be a non-empty filesystem path.');
618
+ }
619
+ return path;
620
+ }
621
+
622
+ function isObject(value: unknown): value is Record<string, unknown> {
623
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
624
+ }
625
+
626
+ export function parseProvenTopologyReceipt(input: {
627
+ contents: string;
628
+ plan: ProductionTopologyPlan;
629
+ }): ProvenProductionTopology {
630
+ let parsed: unknown;
631
+ try {
632
+ parsed = JSON.parse(input.contents);
633
+ } catch {
634
+ throw new Error('Topology proof receipt is not valid JSON.');
635
+ }
636
+ if (
637
+ !isObject(parsed) ||
638
+ parsed.phase !== ProductionTopologyControllerPhase.Prove ||
639
+ parsed.releaseSha !== input.plan.releaseSha ||
640
+ typeof parsed.stagedVercelUrl !== 'string'
641
+ ) {
642
+ throw new Error('Topology proof receipt does not match this release SHA.');
643
+ }
644
+ return {
645
+ phase: ProductionTopologyControllerPhase.Prove,
646
+ plan: input.plan,
647
+ stagedVercelUrl: assertStagedVercelUrl(parsed.stagedVercelUrl),
648
+ };
649
+ }
650
+
651
+ function writeProvenTopologyReceipt(input: {
652
+ file: string;
653
+ proven: ProvenProductionTopology;
654
+ }): void {
655
+ const file = proofReceiptPath(input.file);
656
+ const temporary = `${file}.tmp-${process.pid}`;
657
+ writeFileSync(
658
+ temporary,
659
+ `${JSON.stringify(topologyProofReceipt(input.proven))}\n`,
660
+ {
661
+ mode: 0o600,
662
+ },
663
+ );
664
+ renameSync(temporary, file);
665
+ }
666
+
667
+ function readProvenTopologyReceipt(input: {
668
+ file: string;
669
+ plan: ProductionTopologyPlan;
670
+ }): ProvenProductionTopology {
671
+ const file = proofReceiptPath(input.file);
672
+ return parseProvenTopologyReceipt({
673
+ contents: readFileSync(file, 'utf8'),
674
+ plan: input.plan,
675
+ });
676
+ }
677
+
678
+ /**
679
+ * Commit accepts only the typed result of a completed proof. It never waits,
680
+ * creates a replacement, runs a Play, or compensates an ambiguous cutover.
681
+ * The exact fenced Fly operation remains available for an idempotent retry.
682
+ */
683
+ export async function commitProductionTopologyPlan(input: {
684
+ proven: ProvenProductionTopology;
685
+ environment: Record<string, string>;
686
+ command: RuntimeTopologyCommand;
687
+ }): Promise<CommittedProductionTopology> {
688
+ const environment = {
689
+ ...input.environment,
690
+ GITHUB_SHA: input.proven.plan.releaseSha,
691
+ };
692
+ await input.command({
693
+ command: 'bun',
694
+ args: [
695
+ 'scripts/roll-receipt-gateway.ts',
696
+ 'activate',
697
+ '--environment',
698
+ 'production',
699
+ '--sha',
700
+ input.proven.plan.releaseSha,
701
+ '--apply',
702
+ ],
703
+ environment,
704
+ description: 'Activate proven receipt gateway candidates',
705
+ });
706
+ await input.command({
707
+ command: 'bun',
708
+ args: [
709
+ 'scripts/roll-execution-relay.ts',
710
+ 'activate',
711
+ '--environment',
712
+ 'production',
713
+ '--sha',
714
+ input.proven.plan.releaseSha,
715
+ '--apply',
716
+ ],
717
+ environment,
718
+ description: 'Activate proven execution relay generation',
719
+ });
720
+ // A receipt fixture can prove a cordoned candidate path, but it cannot show
721
+ // that Fly's serving route has switched every Machine. Promotion is blocked
722
+ // until both request-serving roles prove the exact candidate image and the
723
+ // canonical reader capability through forced-instance readiness probes.
724
+ await input.command({
725
+ command: 'bun',
726
+ args: [
727
+ 'scripts/runtime-gateway-fleet.ts',
728
+ 'verify-reader-capability',
729
+ '--environment',
730
+ 'production',
731
+ '--role',
732
+ 'all',
733
+ '--sha',
734
+ input.proven.plan.releaseSha,
735
+ '--apply',
736
+ ],
737
+ environment,
738
+ description: 'Prove active receipt and execution gateway fleet',
739
+ });
740
+ return {
741
+ phase: ProductionTopologyControllerPhase.Commit,
742
+ plan: input.proven.plan,
743
+ stagedVercelUrl: input.proven.stagedVercelUrl,
744
+ };
745
+ }
746
+
747
+ /**
748
+ * Revert only the exact Fly operation identified by the failed candidate SHA.
749
+ * Each role restores its retained source generation before cordoning green;
750
+ * this command never creates, destroys, or guesses a replacement Machine.
751
+ */
752
+ export async function rollbackProductionTopologyPlan(input: {
753
+ plan: ProductionTopologyPlan;
754
+ environment: Record<string, string>;
755
+ command: RuntimeTopologyCommand;
756
+ }): Promise<void> {
757
+ const environment = releaseEnvironment(input);
758
+ await input.command({
759
+ command: 'bun',
760
+ args: [
761
+ 'scripts/roll-receipt-gateway.ts',
762
+ 'rollback',
763
+ '--environment',
764
+ 'production',
765
+ '--sha',
766
+ input.plan.releaseSha,
767
+ '--apply',
768
+ ],
769
+ environment,
770
+ description: 'Restore retained receipt gateway generation',
771
+ });
772
+ await input.command({
773
+ command: 'bun',
774
+ args: [
775
+ 'scripts/roll-execution-relay.ts',
776
+ 'rollback',
777
+ '--environment',
778
+ 'production',
779
+ '--sha',
780
+ input.plan.releaseSha,
781
+ '--apply',
782
+ ],
783
+ environment,
784
+ description: 'Restore retained execution relay generation',
785
+ });
786
+ }
787
+
788
+ type Flags = Map<string, string | boolean>;
789
+
790
+ function flags(argv: string[]): Flags {
791
+ const result = new Map<string, string | boolean>();
792
+ for (let index = 0; index < argv.length; index += 1) {
793
+ const token = argv[index]!;
794
+ if (!token.startsWith('--'))
795
+ throw new Error(`Unexpected argument ${token}.`);
796
+ const key = token.slice(2);
797
+ const value = argv[index + 1];
798
+ if (!value || value.startsWith('--')) result.set(key, true);
799
+ else {
800
+ result.set(key, value);
801
+ index += 1;
802
+ }
803
+ }
804
+ return result;
805
+ }
806
+
807
+ function requireValue(input: Flags, key: string): string {
808
+ const value = input.get(key);
809
+ if (typeof value !== 'string') throw new Error(`--${key} is required.`);
810
+ return value;
811
+ }
812
+
813
+ function requirePrepareEnvironment(): Record<string, string> {
814
+ return requireEnvironmentValues([
815
+ 'FLY_API_TOKEN',
816
+ 'FLY_ORG',
817
+ 'WORKFLOW_RUNTIME_POSTGRES',
818
+ 'DEEPLINE_INTERNAL_TOKEN',
819
+ ]);
820
+ }
821
+
822
+ function requireCommitEnvironment(): Record<string, string> {
823
+ return requireEnvironmentValues([
824
+ 'FLY_API_TOKEN',
825
+ 'GITHUB_REPOSITORY',
826
+ 'GH_TOKEN',
827
+ ]);
828
+ }
829
+
830
+ function requireRollbackEnvironment(): Record<string, string> {
831
+ return requireEnvironmentValues(['FLY_API_TOKEN']);
832
+ }
833
+
834
+ function requireProofEnvironment(): Record<string, string> {
835
+ return requireEnvironmentValues([
836
+ 'FLY_API_TOKEN',
837
+ 'DEEPLINE_API_KEY',
838
+ 'DEEPLINE_INTERNAL_TOKEN',
839
+ 'VERCEL_PROTECTION_BYPASS_TOKEN',
840
+ 'GITHUB_REPOSITORY',
841
+ 'GH_TOKEN',
842
+ ]);
843
+ }
844
+
845
+ function requireEnvironmentValues(
846
+ names: readonly string[],
847
+ ): Record<string, string> {
848
+ const missing = names.filter((name) => !process.env[name]?.trim());
849
+ if (missing.length)
850
+ throw new Error(`Topology apply is missing: ${missing.join(', ')}.`);
851
+ return Object.fromEntries(
852
+ names.map((name) => [name, process.env[name]!]),
853
+ ) as Record<string, string>;
854
+ }
855
+
856
+ function requireCleanupEnvironment(): Record<string, string> {
857
+ if (!process.env.FLY_API_TOKEN?.trim()) {
858
+ throw new Error('Topology cleanup is missing FLY_API_TOKEN.');
859
+ }
860
+ return { FLY_API_TOKEN: process.env.FLY_API_TOKEN };
861
+ }
862
+
863
+ function assertOnlyFlags(input: Flags, allowed: readonly string[]): void {
864
+ for (const key of input.keys()) {
865
+ if (!allowed.includes(key)) {
866
+ throw new Error(`--${key} is not supported for this topology command.`);
867
+ }
868
+ }
869
+ }
870
+
871
+ function requireProductionApply(input: Flags): void {
872
+ if (
873
+ input.get('environment') !== 'production' ||
874
+ input.get('apply') !== true
875
+ ) {
876
+ throw new Error('Requires --environment production --apply.');
877
+ }
878
+ }
879
+
880
+ function outputTopologyResult(
881
+ result:
882
+ | PreparedProductionTopology
883
+ | ProvenProductionTopology
884
+ | CommittedProductionTopology,
885
+ ): void {
886
+ writeGithubStepOutput('topology_phase', result.phase);
887
+ writeGithubStepOutput('topology_release_sha', result.plan.releaseSha);
888
+ if (
889
+ result.phase === ProductionTopologyControllerPhase.Prove ||
890
+ result.phase === ProductionTopologyControllerPhase.Commit
891
+ ) {
892
+ writeGithubStepOutput('topology_staged_vercel_url', result.stagedVercelUrl);
893
+ }
894
+ }
895
+
896
+ async function main(): Promise<void> {
897
+ const command = process.argv[2] as RuntimeTopologyCliCommand | undefined;
898
+ const input = flags(process.argv.slice(3));
899
+ if (!command || !Object.values(RuntimeTopologyCliCommand).includes(command)) {
900
+ throw new Error(
901
+ 'Usage: runtime-topology.ts prepare|prove|commit|rollback|cleanup --environment production --sha <full-sha> --apply',
902
+ );
903
+ }
904
+ const commonFlags = ['environment', 'sha'] as const;
905
+ const allowedFlags: Record<RuntimeTopologyCliCommand, readonly string[]> = {
906
+ [RuntimeTopologyCliCommand.Prepare]: [...commonFlags, 'apply'],
907
+ [RuntimeTopologyCliCommand.Prove]: [
908
+ ...commonFlags,
909
+ 'apply',
910
+ 'staged-url',
911
+ 'proof-file',
912
+ 'fixture-application-dir',
913
+ ],
914
+ [RuntimeTopologyCliCommand.Commit]: [...commonFlags, 'apply', 'proof-file'],
915
+ [RuntimeTopologyCliCommand.Rollback]: [...commonFlags, 'apply'],
916
+ [RuntimeTopologyCliCommand.Cleanup]: [...commonFlags, 'apply', 'intent'],
917
+ };
918
+ assertOnlyFlags(input, allowedFlags[command]);
919
+ if (input.get('environment') !== 'production') {
920
+ throw new Error('Only --environment production is supported.');
921
+ }
922
+ const release = {
923
+ releaseSha: requireValue(input, 'sha'),
924
+ };
925
+ const intent = input.get('intent');
926
+ if (intent !== undefined && intent !== 'release' && intent !== 'cleanup') {
927
+ throw new Error('--intent must be release or cleanup.');
928
+ }
929
+ const plan =
930
+ intent === 'cleanup'
931
+ ? planProductionTopologyCleanup(release)
932
+ : planProductionTopologyRelease(release);
933
+ requireProductionApply(input);
934
+ if (command === RuntimeTopologyCliCommand.Cleanup) {
935
+ if (intent === 'release') {
936
+ throw new Error('cleanup cannot use --intent release.');
937
+ }
938
+ await cleanupProductionGatewayCandidates({
939
+ plan: planProductionTopologyCleanup(release),
940
+ environment: requireCleanupEnvironment(),
941
+ command: runCommand,
942
+ });
943
+ return;
944
+ }
945
+ if (command === RuntimeTopologyCliCommand.Prepare && intent !== undefined) {
946
+ throw new Error('prepare does not support --intent.');
947
+ }
948
+ if (command === RuntimeTopologyCliCommand.Prove && intent !== undefined) {
949
+ throw new Error('prove does not support --intent.');
950
+ }
951
+ if (command === RuntimeTopologyCliCommand.Commit && intent !== undefined) {
952
+ throw new Error('commit does not support --intent.');
953
+ }
954
+ if (intent === 'cleanup') {
955
+ throw new Error('Use the cleanup command for --intent cleanup.');
956
+ }
957
+ if (command === RuntimeTopologyCliCommand.Prepare) {
958
+ outputTopologyResult(
959
+ await prepareProductionTopologyPlan({
960
+ plan,
961
+ environment: requirePrepareEnvironment(),
962
+ command: runCommand,
963
+ parallelCommand: runRuntimeTopologyParallelCommand,
964
+ }),
965
+ );
966
+ return;
967
+ }
968
+ if (command === RuntimeTopologyCliCommand.Commit) {
969
+ outputTopologyResult(
970
+ await commitProductionTopologyPlan({
971
+ proven: readProvenTopologyReceipt({
972
+ file: requireValue(input, 'proof-file'),
973
+ plan,
974
+ }),
975
+ environment: requireCommitEnvironment(),
976
+ command: runCommand,
977
+ }),
978
+ );
979
+ return;
980
+ }
981
+ if (command === RuntimeTopologyCliCommand.Rollback) {
982
+ await rollbackProductionTopologyPlan({
983
+ plan,
984
+ environment: requireRollbackEnvironment(),
985
+ command: runCommand,
986
+ });
987
+ return;
988
+ }
989
+ if (command === RuntimeTopologyCliCommand.Prove) {
990
+ const fixtureApplicationDirectory = input.get('fixture-application-dir');
991
+ if (
992
+ fixtureApplicationDirectory !== undefined &&
993
+ typeof fixtureApplicationDirectory !== 'string'
994
+ ) {
995
+ throw new Error('prove --fixture-application-dir requires a directory.');
996
+ }
997
+ const proven = await proveProductionTopologyPlan({
998
+ prepared: {
999
+ phase: ProductionTopologyControllerPhase.Prepare,
1000
+ plan,
1001
+ },
1002
+ stagedVercelUrl: requireValue(input, 'staged-url'),
1003
+ fixtureApplicationDirectory,
1004
+ environment: requireProofEnvironment(),
1005
+ command: runCommand,
1006
+ });
1007
+ writeProvenTopologyReceipt({
1008
+ file: requireValue(input, 'proof-file'),
1009
+ proven,
1010
+ });
1011
+ outputTopologyResult(proven);
1012
+ return;
1013
+ }
1014
+ }
1015
+
1016
+ export async function runRuntimeTopologyCli(): Promise<void> {
1017
+ const removeCancellationHandlers =
1018
+ installRuntimeTopologyCancellationHandlers();
1019
+ try {
1020
+ await main();
1021
+ } finally {
1022
+ removeCancellationHandlers();
1023
+ }
1024
+ }