stim 1.14.0 → 1.16.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.
Files changed (152) hide show
  1. package/README.md +2 -0
  2. package/dist/activity-BxwdD81S.mjs +609 -0
  3. package/dist/agent-device-usage-output-Dpih1Tfa.mjs +411 -0
  4. package/dist/{android-d6rGq1pA.mjs → android-B_NumVyQ.mjs} +169 -81
  5. package/dist/{android-LbPFRWVL.mjs → android-Ift4kVvt.mjs} +517 -413
  6. package/dist/android-cas-8U4DPqeR.mjs +167 -0
  7. package/dist/android-cas-compiler.mjs +2 -2
  8. package/dist/{android-XpvCrV7q.mjs → android-r2rBL8kV.mjs} +2 -2
  9. package/dist/{app-install-BpgSgkJj.mjs → app-install-CKWdbXIX.mjs} +128 -151
  10. package/dist/attempt-Bk2ouQVL.mjs +39 -0
  11. package/dist/budget-DjrURfQn.mjs +2700 -0
  12. package/dist/{build-plan-Dcr-3ie0.mjs → build-plan-DjeSb2Xl.mjs} +192 -653
  13. package/dist/build-slots-rUk77xqh.mjs +132 -0
  14. package/dist/{cache-manifest-C68QQ0ua.mjs → cache-manifest-DnUHBJaU.mjs} +2 -2
  15. package/dist/cache-manifest.mjs +1 -1
  16. package/dist/{chrome-BPCiC3ZX.mjs → chrome-BrcVWQyD.mjs} +1 -1
  17. package/dist/cli-BkYZ_6hy.mjs +51 -0
  18. package/dist/cli.mjs +6 -42
  19. package/dist/client-BTY686R6.mjs +1368 -0
  20. package/dist/collector-run.mjs +9 -195
  21. package/dist/{command-output-DVcpw6eE.mjs → command-output-DnJMdEY-.mjs} +1 -1
  22. package/dist/{config-lvc7d3C2.mjs → config-D0DbpZtM.mjs} +28 -29
  23. package/dist/{created-devices-CllH_8eb.mjs → created-devices-D5owjN4C.mjs} +2 -2
  24. package/dist/{dependency-state-DsT7aKeo.mjs → dependency-state-B6cMXZab.mjs} +2 -2
  25. package/dist/{deps-D9IcKqXS.mjs → deps-urbmfXig.mjs} +3 -3
  26. package/dist/{detached-entry-DJHoca49.mjs → detached-entry-CiTEgMR1.mjs} +1 -1
  27. package/dist/dev-client-CroAIjjj.mjs +43 -0
  28. package/dist/{device-CjuuAJjQ.mjs → device-DZQjF7H5.mjs} +8 -7
  29. package/dist/device-capacity-BHwxNBlu.mjs +136 -0
  30. package/dist/device-host-worker.d.mts +1 -0
  31. package/dist/device-host-worker.mjs +964 -0
  32. package/dist/device-ios-BadmeL2V.mjs +381 -0
  33. package/dist/{device-lease-5c3WrVRm.mjs → device-lease-D-hLqx1b.mjs} +43 -36
  34. package/dist/{device-pool-BiyDyPP-.mjs → device-lease-run-CqjShqWW.mjs} +6 -126
  35. package/dist/device-pool-CUaEWMMm.mjs +125 -0
  36. package/dist/{device-remote-D87zEzaq.mjs → device-remote-D5WVWqOq.mjs} +69 -163
  37. package/dist/{doctor-x9pnU9fq.mjs → doctor-Cimf-gGB.mjs} +241 -103
  38. package/dist/{error-diagnostics-C5HzcwCW.mjs → error-diagnostics-B9L4Ae0x.mjs} +15 -18
  39. package/dist/{exec-DK8KaVHK.mjs → exec-CaErVv7t.mjs} +5 -1
  40. package/dist/{gc-ko6mUAYD.mjs → gc-Bqy4bjEu.mjs} +599 -108
  41. package/dist/{android-cas-iZK3DOCr.mjs → gradle-BsO1MdsH.mjs} +8 -294
  42. package/dist/{guide-6R0VNFqI.mjs → guide-B_uyKTxW.mjs} +2511 -239
  43. package/dist/{guide-status-BZrFPIA_.mjs → guide-status-CjEZMAD4.mjs} +3 -3
  44. package/dist/hosted-client-a-WmqE2L.mjs +197 -0
  45. package/dist/hosted-ios-CIWelIqf.mjs +220 -0
  46. package/dist/hosted-logs-D5g041Q3.mjs +187 -0
  47. package/dist/hosted-macos-BSZyIb3y.mjs +135 -0
  48. package/dist/idle-DYVymG92.mjs +1099 -0
  49. package/dist/idle-shutdown-CTsDnAde.mjs +122 -0
  50. package/dist/in-use-DvBw0Kxo.mjs +130 -0
  51. package/dist/{ios-BwDPrnYQ.mjs → ios-BaInn3sW.mjs} +201 -10
  52. package/dist/ios-Cr1h5gnO.mjs +189 -0
  53. package/dist/{ios-P1f5nm2n.mjs → ios-aq3QoGI3.mjs} +1159 -633
  54. package/dist/{ios-device-BiOsSVzd.mjs → ios-device-Ee7sDXfp.mjs} +1 -1
  55. package/dist/{ios-device-DGglU3E_.mjs → ios-device-FfCN_C8p.mjs} +12 -7
  56. package/dist/ios-state-DS5UpYTH.mjs +48 -0
  57. package/dist/{launch-verify-Deg4tB3Z.mjs → launch-verify-2DBRizjo.mjs} +5 -5
  58. package/dist/logs-CHEVK8zD.mjs +371 -0
  59. package/dist/machines-BIBUdhpj.mjs +265 -0
  60. package/dist/macos-CSYEAFmF.mjs +473 -0
  61. package/dist/macos-run.d.mts +5 -0
  62. package/dist/macos-run.mjs +2 -0
  63. package/dist/maintenance-run.d.mts +4 -0
  64. package/dist/maintenance-run.mjs +254 -0
  65. package/dist/{metro-CcEo8Fl8.mjs → metro-BeK1ZPz1.mjs} +20 -120
  66. package/dist/metro-gateway-BJ-rxQAB.mjs +84 -0
  67. package/dist/{named-ports-Blg8IxOh.mjs → named-ports-Bk_YXEmE.mjs} +6 -59
  68. package/dist/native-run-NaBrRFtQ.mjs +147 -0
  69. package/dist/{native-runtime-DjBHRg5m.mjs → native-runtime-DBzRnEKS.mjs} +9 -5
  70. package/dist/{ndjson-CDY9IkVC.mjs → ndjson-PclOIwVY.mjs} +2 -2
  71. package/dist/offload-worker.d.mts +122 -0
  72. package/dist/offload-worker.mjs +606 -0
  73. package/dist/{ownership-DyYDF5ww.mjs → ownership-DiA16AsN.mjs} +3 -2
  74. package/dist/ownership-hCkPzGmr.mjs +571 -0
  75. package/dist/{page-BHUzTDFt.mjs → page-QF_Ke10s.mjs} +7 -2
  76. package/dist/{ports-BguS83ex.mjs → ports-R2k4Knz3.mjs} +3 -3
  77. package/dist/prebuild-D2fsca_Z.mjs +213 -0
  78. package/dist/preview-DhVbvPAZ.mjs +318 -0
  79. package/dist/process-identity-BmRm1qV0.mjs +12 -0
  80. package/dist/{project-CWlvEaeN.mjs → project-Du91Ilvc.mjs} +74 -19
  81. package/dist/pull-request-pxnDCQzn.mjs +322 -0
  82. package/dist/pull-requests.d.mts +120 -0
  83. package/dist/pull-requests.mjs +2 -0
  84. package/dist/recordings-BIXOzVnr.mjs +37 -0
  85. package/dist/{reload-BiniwsSZ.mjs → reload-rM9jV1fz.mjs} +109 -24
  86. package/dist/{remote-cache-CcR6o6lI.mjs → remote-cache-C8-V13XN.mjs} +5 -5
  87. package/dist/run-f4vI6EnK.mjs +119 -0
  88. package/dist/{server-bare-DGfgyJ5J.mjs → server-bare-CpPGmnmO.mjs} +33 -15
  89. package/dist/server-expo-CVqO2c35.mjs +2 -0
  90. package/dist/{server-expo-BVuwQ65v.mjs → server-expo-MC0rHlOC.mjs} +8 -12
  91. package/dist/settings-BBpMcDbD.mjs +25 -0
  92. package/dist/{settings-B-fsC-Om.mjs → settings-ZkDzkzvB.mjs} +22 -10
  93. package/dist/settings-oTSkkNrM.mjs +1439 -0
  94. package/dist/settings.schema.json +845 -97
  95. package/dist/{simslim-DrDN4WuV.mjs → simslim-B8-7AoTI.mjs} +8 -132
  96. package/dist/{slot-launch-D5t8EiMf.mjs → slot-launch-B8VrHxn1.mjs} +11 -8
  97. package/dist/{spawn-claims-Drw_o-Fb.mjs → spawn-claims-D0XfSe8u.mjs} +1 -1
  98. package/dist/spawn-entry-jw19m8Nl.mjs +17 -0
  99. package/dist/stage-CzaiwFsI.mjs +143 -0
  100. package/dist/start-CX8Ig-U2.mjs +2 -0
  101. package/dist/{start-s4RXp1wy.mjs → start-ClDfwXa4.mjs} +116 -65
  102. package/dist/state-3DwSQIBK.mjs +41 -0
  103. package/dist/state-B4PQFXMW.d.mts +12 -0
  104. package/dist/{state-Dk9RVXI7.mjs → state-C1jB7Q8q.mjs} +70 -8
  105. package/dist/{state-DHqvsbqC.mjs → state-DcW6vOd_.mjs} +7 -4
  106. package/dist/stats-CEZ_i5je.mjs +77 -0
  107. package/dist/{status-BgAuxESg.mjs → status-BuvBFeII.mjs} +118 -20
  108. package/dist/{status-BsPAnfOI.mjs → status-Cs7VLg9D.mjs} +542 -283
  109. package/dist/status-DlKvVJBE.mjs +49 -0
  110. package/dist/stdout-reader-C8yjre1x.mjs +23 -0
  111. package/dist/stim-desktop-Cnz8CPnl.mjs +63 -0
  112. package/dist/stim-footprint.swift +32 -0
  113. package/dist/{stim-installations-CINXy_su.mjs → stim-installations-QMEI32v1.mjs} +1 -1
  114. package/dist/stop-B0Yxtmiy.mjs +48 -0
  115. package/dist/{stop-BzreOWJp.mjs → stop-Bffozsac.mjs} +83 -16
  116. package/dist/{stop-BWIF_6BK.mjs → stop-Dt_dM16A.mjs} +2 -2
  117. package/dist/stop-cause-CidxIqup.mjs +97 -0
  118. package/dist/supervisor-run.d.mts +43 -11
  119. package/dist/supervisor-run.mjs +363 -24
  120. package/dist/support-CAP8qDUF.mjs +316 -0
  121. package/dist/support-D4aYJniO.mjs +201 -0
  122. package/dist/teardown-CL6vDs2Y.mjs +727 -0
  123. package/dist/{xcode-Cp5KvK9x.mjs → toolchain-DkUflduf.mjs} +364 -14
  124. package/dist/trigger-Bemu8ytw.mjs +65 -0
  125. package/dist/trigger-CXts6qt1.mjs +2 -0
  126. package/dist/warm-progress-CpGv3Wb6.mjs +732 -0
  127. package/dist/{web-BsQs1WlO.mjs → web-DWUaMp8P.mjs} +37 -21
  128. package/dist/web-run.mjs +310 -13
  129. package/dist/workspace-process-lock-BL7Q0HCO.mjs +57 -0
  130. package/dist/workspace-state-B8-aJ1_5.mjs +371 -0
  131. package/dist/workspaces-BV4pdHFz.mjs +306 -0
  132. package/dist/{worktree-UrQWXSeb.mjs → worktree-K-xj9Db0.mjs} +625 -256
  133. package/dist/worktree-jnG8Iph5.mjs +2 -0
  134. package/package.json +10 -5
  135. package/shim/bundle-response.cjs +28 -12
  136. package/dist/activity-CYVDcrt3.mjs +0 -356
  137. package/dist/budget-CGDkhvv_.mjs +0 -1745
  138. package/dist/build-progress-DxKpwnaS.mjs +0 -247
  139. package/dist/idle-DXtx3RbX.mjs +0 -191
  140. package/dist/in-use-DlqBDKqY.mjs +0 -249
  141. package/dist/logs-Civ4gbBH.mjs +0 -486
  142. package/dist/merge-state-ytFQCeh4.mjs +0 -262
  143. package/dist/server-expo-DIHDhvGg.mjs +0 -2
  144. package/dist/settings-D61iNOsB.mjs +0 -629
  145. package/dist/start-Cetfvy8l.mjs +0 -2
  146. package/dist/stats-CFpFGlJV.mjs +0 -62
  147. package/dist/stats-CX1Z8Tca.mjs +0 -368
  148. package/dist/stim-desktop-CXt8-Z3J.mjs +0 -40
  149. package/dist/stop-cause-hjGBOZTG.mjs +0 -1231
  150. package/dist/worktree-BVrHsYZG.mjs +0 -752
  151. package/dist/worktree-CHWWvm5v.mjs +0 -2
  152. package/dist/{metro-store-DzeWkfqW.mjs → metro-store-CDDZHtPl.mjs} +1 -1
@@ -0,0 +1,1368 @@
1
+ import { t as getExecutor } from "./exec-CaErVv7t.mjs";
2
+ import { _ as loadConfig$1, d as getConfigDir } from "./config-D0DbpZtM.mjs";
3
+ import { w as withDirLock } from "./workspace-state-B8-aJ1_5.mjs";
4
+ import { T as CCACHE_UNAVAILABLE, a as toolchainMismatches, l as COMPILATION_CACHE_UNAVAILABLE, s as manifestDigest } from "./toolchain-DkUflduf.mjs";
5
+ import { createHash } from "node:crypto";
6
+ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync, writeSync } from "node:fs";
7
+ import { basename, dirname, join, relative } from "node:path";
8
+ import { InvalidArgumentError } from "commander";
9
+ import { OFFLOAD_MODES, buildMachinesFile, buildMachinesLock, endpoint, findPeer as findPeer$1, isJsonObject, parseMachine, parseMachine as parseMachine$1, pinnedEndpoint, readBuildMachines, saturation } from "@stim-cli/core/state";
10
+ import { hostname } from "node:os";
11
+ import { WebSocket } from "ws";
12
+ //#region src/offload/access-ticket.ts
13
+ function readAccessTicket() {
14
+ const ticket = process.env.STIM_ACCESS_TICKET?.trim();
15
+ return ticket && /^[A-Za-z0-9_-]{43}$/.test(ticket) ? {
16
+ ticket,
17
+ ticketHash: createHash("sha256").update(ticket).digest("hex")
18
+ } : void 0;
19
+ }
20
+ function readHostPermissions(value) {
21
+ const host = value;
22
+ return host && typeof host.name === "string" && typeof host.screenRecording === "boolean" && typeof host.accessibility === "boolean" ? {
23
+ name: host.name,
24
+ screenRecording: host.screenRecording,
25
+ accessibility: host.accessibility
26
+ } : void 0;
27
+ }
28
+ //#endregion
29
+ //#region src/offload/tailnet.ts
30
+ const MAC_APP_TAILSCALE = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
31
+ const HELLO_TIMEOUT_MS = 1e4;
32
+ function tailscaleStatus() {
33
+ for (const binary of ["tailscale", MAC_APP_TAILSCALE]) {
34
+ const output = getExecutor().runFileQuiet(binary, ["status", "--json"], { timeoutMs: 5e3 });
35
+ if (output === null) continue;
36
+ try {
37
+ const status = JSON.parse(output);
38
+ return isJsonObject(status) && status.BackendState === "Running" ? status : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+ function hello({ url, servername, host }, auth) {
46
+ return new Promise((resolve) => {
47
+ const socket = new WebSocket(url, {
48
+ handshakeTimeout: HELLO_TIMEOUT_MS,
49
+ servername,
50
+ headers: { Host: host }
51
+ });
52
+ const done = (reply) => {
53
+ clearTimeout(timer);
54
+ socket.removeAllListeners();
55
+ socket.on("error", () => {});
56
+ socket.close();
57
+ resolve(reply);
58
+ };
59
+ const timer = setTimeout(() => done({ failed: "no reply in time" }), HELLO_TIMEOUT_MS);
60
+ socket.on("open", () => {
61
+ socket.send(JSON.stringify({
62
+ id: 1,
63
+ method: "hello",
64
+ params: {
65
+ protocol: 1,
66
+ client: {
67
+ name: "stim",
68
+ version: "1"
69
+ },
70
+ auth
71
+ }
72
+ }));
73
+ });
74
+ socket.on("message", (data) => {
75
+ let message;
76
+ try {
77
+ message = JSON.parse(String(data));
78
+ } catch {
79
+ message = null;
80
+ }
81
+ if (isJsonObject(message) && ("result" in message || "error" in message)) return done(message);
82
+ done({ failed: "the reply was not a hello result" });
83
+ });
84
+ socket.on("error", (error) => done({ failed: error.message }));
85
+ socket.on("close", () => done({ failed: "the connection closed before a reply" }));
86
+ });
87
+ }
88
+ const realIo = {
89
+ status: tailscaleStatus,
90
+ hello
91
+ };
92
+ function pinnedEndpoint$1(credential, status = tailscaleStatus) {
93
+ return pinnedEndpoint(credential, parseMachine(credential.machine) ? status() : null);
94
+ }
95
+ //#endregion
96
+ //#region src/offload/build-machines.ts
97
+ /** The build machines named in `offload.machines` that this Mac asked for build access, in that order. */
98
+ function pairedMachines(entries = configuredMachines()) {
99
+ const credentials = readBuildMachines();
100
+ return entries.flatMap((entry) => credentials.filter((each) => each.machine === entry));
101
+ }
102
+ function configuredMachines() {
103
+ const machines = loadConfig$1()?.offload?.machines;
104
+ return Array.isArray(machines) ? machines.filter((entry) => typeof entry === "string") : [];
105
+ }
106
+ function updateCredentials(change) {
107
+ const dir = getConfigDir();
108
+ withDirLock(buildMachinesLock(), () => {
109
+ const file = buildMachinesFile();
110
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
111
+ writeFileSync(tmp, `${JSON.stringify({
112
+ version: 1,
113
+ machines: change(readBuildMachines())
114
+ }, null, 2)}\n`, { mode: 384 });
115
+ try {
116
+ renameSync(tmp, file);
117
+ } catch (error) {
118
+ rmSync(tmp, { force: true });
119
+ throw error;
120
+ }
121
+ }, { ensureParent: () => mkdirSync(dir, { recursive: true }) });
122
+ }
123
+ function note(title, detail, fix = null) {
124
+ return {
125
+ code: "build-machine",
126
+ level: "note",
127
+ title,
128
+ detail,
129
+ fix
130
+ };
131
+ }
132
+ const approval = (entry, deviceId) => `A person on ${entry} approves it with \`stim-server devices grant ${deviceId} --build\`.`;
133
+ const PROBLEM_TITLES = {
134
+ unreachable: "does not answer a build offer",
135
+ checkout: "cannot build an app outside a git checkout",
136
+ "stim-build": "runs another Stim build",
137
+ arch: "has another CPU architecture",
138
+ xcode: "has another Xcode",
139
+ "macos-sdk": "has another macOS SDK",
140
+ "simulator-sdk": "has another simulator SDK",
141
+ cocoapods: "has another CocoaPods",
142
+ bundler: "has no Bundler",
143
+ runtime: "has no simulator for this project's iOS runtime",
144
+ jdk: "runs another JDK major version",
145
+ "android-sdk": "has no Android SDK",
146
+ ndk: "lacks this project's NDK",
147
+ "build-tools": "lacks this project's Android build-tools",
148
+ "compile-sdk": "lacks this project's Android compile platform",
149
+ disk: "is low on disk",
150
+ busy: "is too busy to take builds"
151
+ };
152
+ function problemFix(code, entry) {
153
+ switch (code) {
154
+ case "unreachable": return `Check that stim-server runs on ${entry} and that its tailscale serve route answers, then run \`stim doctor\` again. To keep it running there, run \`stim-server service install --serve\` on ${entry}.`;
155
+ case "checkout": return "Offload syncs the files git lists; run Stim from a git checkout of the app.";
156
+ case "stim-build": return `Update stim-server on ${entry} to the same Stim build as this Mac: there, run \`stim-server service update --release <version>\` for this Mac's release, or \`--from <dir>\` with the packed packages of this Mac's checkout.`;
157
+ case "arch": return "Use a build machine with the same CPU architecture as this Mac.";
158
+ case "xcode":
159
+ case "simulator-sdk":
160
+ case "macos-sdk": return `Install and select the same Xcode on ${entry} and this Mac (\`xcode-select -p\` on each).`;
161
+ case "cocoapods": return `Install the same CocoaPods version on ${entry}, on the PATH its stim-server's login shell sets.`;
162
+ case "bundler": return `Install Bundler (\`gem install bundler\`) on ${entry}, on the PATH its stim-server's login shell sets.`;
163
+ case "runtime": return `Install that iOS simulator runtime on ${entry} with at least one iPhone simulator, or set ios.runtime here to one ${entry} has.`;
164
+ case "jdk": return `Start stim-server on ${entry} with JAVA_HOME at a JDK of the same major version as this Mac's.`;
165
+ case "android-sdk":
166
+ case "ndk":
167
+ case "build-tools":
168
+ case "compile-sdk": return `Install the missing package with sdkmanager in the Android SDK stim-server on ${entry} uses (its ANDROID_HOME, else ~/Library/Android/sdk).`;
169
+ case "disk": return `Free space on ${entry}'s worker root (offload.workerRoot there).`;
170
+ case "busy": return `Builds stay on this Mac until ${entry} has capacity. On ${entry}, offload.maxLoadPerCore sets the load it accepts and concurrency.maxBuilds how many of its own builds it runs; it takes one offloaded build at a time.`;
171
+ }
172
+ }
173
+ const TRANSIENT = /* @__PURE__ */ new Set(["unreachable", "busy"]);
174
+ function offloadFindings(entry, problems) {
175
+ return problems.map((problem) => ({
176
+ code: `build-machine-${problem.code}`,
177
+ level: TRANSIENT.has(problem.code) ? "note" : "cost",
178
+ title: `Build machine ${entry} ${PROBLEM_TITLES[problem.code]}`,
179
+ detail: `Builds fall back to this Mac: ${problem.reason}.`,
180
+ fix: problemFix(problem.code, entry)
181
+ }));
182
+ }
183
+ async function requestAccess(entry, peer, port, deviceName, io) {
184
+ const ticket = readAccessTicket();
185
+ const reply = await io.hello(endpoint(peer, port), {
186
+ request: "build",
187
+ deviceName,
188
+ ...ticket ? { setupTicket: ticket.ticket } : {}
189
+ });
190
+ if (!("result" in reply) || !reply.result.approval || !reply.result.deviceToken) {
191
+ const reason = "failed" in reply ? reply.failed : "error" in reply ? reply.error.message : "it granted no request";
192
+ return {
193
+ report: {
194
+ machine: entry,
195
+ state: "unreachable",
196
+ dnsName: peer.dnsName
197
+ },
198
+ finding: note(`Could not ask ${entry} for build access`, reason)
199
+ };
200
+ }
201
+ const credential = {
202
+ machine: entry,
203
+ nodeId: peer.nodeId,
204
+ dnsName: peer.dnsName,
205
+ deviceId: reply.result.device.id,
206
+ deviceToken: reply.result.deviceToken,
207
+ state: "pending",
208
+ requestedAt: (/* @__PURE__ */ new Date()).toISOString(),
209
+ ...ticket ? { ticketHash: ticket.ticketHash } : {}
210
+ };
211
+ updateCredentials((credentials) => [...credentials.filter((each) => each.machine !== entry), credential]);
212
+ return {
213
+ report: {
214
+ machine: entry,
215
+ state: "pending",
216
+ dnsName: peer.dnsName,
217
+ deviceId: credential.deviceId,
218
+ requestedAt: credential.requestedAt
219
+ },
220
+ finding: note(`Asked ${entry} for build access`, `The request lapses at ${reply.result.approval.expiresAt}.`, approval(entry, credential.deviceId))
221
+ };
222
+ }
223
+ async function inspectMachine(entry, status, deviceName, fix, io, check) {
224
+ const parsed = parseMachine$1(entry);
225
+ if (!parsed) return {
226
+ report: {
227
+ machine: entry,
228
+ state: "invalid"
229
+ },
230
+ finding: note(`Build machine ${entry} is not a tailnet name`, "Expected `name` or `name:port`.")
231
+ };
232
+ const peer = findPeer$1(status, parsed.name);
233
+ if (peer === "missing" || peer === "ambiguous") {
234
+ const detail = peer === "missing" ? "No peer on this tailnet has that name." : "Several peers match that name.";
235
+ return {
236
+ report: {
237
+ machine: entry,
238
+ state: "not-on-tailnet"
239
+ },
240
+ finding: note(`Build machine ${entry} is not on this tailnet`, detail)
241
+ };
242
+ }
243
+ const credential = readBuildMachines().find((each) => each.machine === entry);
244
+ const known = {
245
+ machine: entry,
246
+ dnsName: peer.dnsName
247
+ };
248
+ if (credential && credential.nodeId !== peer.nodeId) return {
249
+ report: {
250
+ ...known,
251
+ state: "node-changed",
252
+ deviceId: credential.deviceId,
253
+ requestedAt: credential.requestedAt
254
+ },
255
+ finding: note(`Build machine ${entry} is a different tailnet node`, `This Mac paired with node ${credential.nodeId}, but ${peer.dnsName} is now node ${peer.nodeId}. Stim does not connect to it.`, `If that Mac was replaced, remove ${entry} from offload.machines, run \`stim doctor --fix\` to forget the old pairing, then add it back and run \`stim doctor --fix\` again.`)
256
+ };
257
+ if (!credential) {
258
+ if (fix) return requestAccess(entry, peer, parsed.port, deviceName, io);
259
+ return {
260
+ report: {
261
+ ...known,
262
+ state: "not-asked"
263
+ },
264
+ finding: note(`Build machine ${entry} has not approved this Mac`, "This Mac has not asked it for build access.", "Run `stim doctor --fix` to ask.")
265
+ };
266
+ }
267
+ const paired = {
268
+ ...known,
269
+ deviceId: credential.deviceId,
270
+ requestedAt: credential.requestedAt
271
+ };
272
+ const reply = await io.hello(endpoint(peer, parsed.port), { deviceToken: credential.deviceToken });
273
+ if ("result" in reply && reply.result.capabilities.includes("build")) {
274
+ const permissions = readHostPermissions(reply.result.host);
275
+ const host = permissions ? { host: permissions } : {};
276
+ if (credential.state !== "approved") updateCredentials((credentials) => credentials.map((each) => each.machine === entry ? {
277
+ ...each,
278
+ state: "approved"
279
+ } : each));
280
+ if (!check) return {
281
+ report: {
282
+ ...paired,
283
+ state: "approved",
284
+ ...host
285
+ },
286
+ finding: null
287
+ };
288
+ const { capacity, problems } = await check({
289
+ ...credential,
290
+ state: "approved"
291
+ });
292
+ return {
293
+ report: {
294
+ ...paired,
295
+ state: "approved",
296
+ ...host,
297
+ offloadable: problems.length === 0,
298
+ reasons: problems.map((problem) => problem.reason),
299
+ problems,
300
+ ...capacity ? { capacity } : {}
301
+ },
302
+ finding: null,
303
+ extra: offloadFindings(entry, problems)
304
+ };
305
+ }
306
+ if ("error" in reply && reply.error.code === "approval-pending") {
307
+ const ticket = fix && credential.state !== "approved" ? readAccessTicket() : void 0;
308
+ if (ticket && credential.ticketHash !== ticket.ticketHash) return requestAccess(entry, peer, parsed.port, deviceName, io);
309
+ return {
310
+ report: {
311
+ ...paired,
312
+ state: "pending"
313
+ },
314
+ finding: note(`Build machine ${entry} has not approved this Mac yet`, `Requested at ${credential.requestedAt}.`, approval(entry, credential.deviceId))
315
+ };
316
+ }
317
+ if ("error" in reply && reply.error.code === "unauthorized") {
318
+ if (fix) return requestAccess(entry, peer, parsed.port, deviceName, io);
319
+ return {
320
+ report: {
321
+ ...paired,
322
+ state: "revoked"
323
+ },
324
+ finding: note(`Build machine ${entry} no longer accepts this Mac`, `${reply.error.message} It was revoked, or the request lapsed before approval.`, "Run `stim doctor --fix` to ask again.")
325
+ };
326
+ }
327
+ const reason = "failed" in reply ? reply.failed : "error" in reply ? reply.error.message : "unexpected reply";
328
+ return {
329
+ report: {
330
+ ...paired,
331
+ state: "unreachable"
332
+ },
333
+ finding: note(`Could not reach build machine ${entry}`, reason, `Check that stim-server runs on ${entry}. To keep it running there, run \`stim-server service install --serve\` on ${entry}.`)
334
+ };
335
+ }
336
+ /**
337
+ * Doctor findings and a report for each `offload.machines` entry. The worker's node is pinned when access is
338
+ * requested; a later connection goes only to the current MagicDNS name of that same node, checked before the token
339
+ * is sent. With `fix`, requests access from a named machine this Mac holds no pairing for, requests again when the
340
+ * pinned node forgot this Mac or, with `STIM_ACCESS_TICKET` set, when a pending request carries another ticket, and forgets pairings of machines no longer named. With `check`, each approved
341
+ * machine is asked for one build offer and reports every reason it would not take this project's build.
342
+ */
343
+ async function inspectBuildMachines({ fix, check = null }, io = realIo, entries = configuredMachines()) {
344
+ const unnamed = (each) => !entries.includes(each.machine);
345
+ if (fix && readBuildMachines().some(unnamed)) updateCredentials((credentials) => credentials.filter((each) => !unnamed(each)));
346
+ if (entries.length === 0) return {
347
+ findings: [],
348
+ machines: []
349
+ };
350
+ const status = io.status();
351
+ if (!isJsonObject(status)) return {
352
+ findings: [note("Build machines are unreachable", "offload.machines names build machines, but Tailscale is not running.")],
353
+ machines: entries.map((machine) => ({
354
+ machine,
355
+ state: "tailscale-off"
356
+ }))
357
+ };
358
+ const self = isJsonObject(status.Self) ? status.Self : {};
359
+ const deviceName = (typeof self.HostName === "string" && self.HostName ? self.HostName : hostname()).replace(/[\p{Cc}\p{Cf}]/gu, "").trim().slice(0, 64) || "Mac";
360
+ const inspection = {
361
+ findings: [],
362
+ machines: []
363
+ };
364
+ const inspected = await Promise.all(entries.map((entry) => inspectMachine(entry, status, deviceName, fix, io, check)));
365
+ for (const { report, finding, extra = [] } of inspected) {
366
+ inspection.machines.push(report);
367
+ if (finding) inspection.findings.push(finding);
368
+ inspection.findings.push(...extra);
369
+ }
370
+ return inspection;
371
+ }
372
+ //#endregion
373
+ //#region src/offload/selection.ts
374
+ var OffloadRefusal = class extends Error {
375
+ code = "STIM_OFFLOAD_REFUSED";
376
+ remedy = "Run stim doctor --fix to ask for build access if not paired. On the worker, a person finds the id with stim-server devices and approves it with stim-server devices grant <id> --build. Check stim settings get offload.machines, or rerun with --build-machine auto or --build-machine local.";
377
+ constructor(machine, reason) {
378
+ super(reason.startsWith(`${machine}: `) ? reason : `${machine}: ${reason}`);
379
+ }
380
+ };
381
+ function resolveBuildMachine(flag, env, setting) {
382
+ const selected = flag ?? (env?.trim() || void 0) ?? setting ?? "auto";
383
+ if (typeof selected !== "string" || !selected.trim() || !parseMachine(selected)) throw Object.assign(/* @__PURE__ */ new Error("--build-machine / offload.machine must be auto, local, or a tailnet machine name."), { code: "STIM_BAD_ARG" });
384
+ const trimmed = selected.trim();
385
+ const reserved = trimmed.toLowerCase();
386
+ return reserved === "auto" || reserved === "local" ? reserved : trimmed;
387
+ }
388
+ function parseBuildMachineOption(value) {
389
+ try {
390
+ return resolveBuildMachine(value);
391
+ } catch (error) {
392
+ throw new InvalidArgumentError(error.message);
393
+ }
394
+ }
395
+ function namedBuildMachine(selected) {
396
+ return selected !== "auto" && selected !== "local";
397
+ }
398
+ function requireConfiguredMachine(selected, machines) {
399
+ if (!namedBuildMachine(selected)) return selected;
400
+ const parsed = parseMachine(selected);
401
+ const entries = Array.isArray(machines) ? machines.filter((entry) => typeof entry === "string") : [];
402
+ const match = entries.find((entry) => {
403
+ const candidate = parseMachine(entry);
404
+ return candidate?.name === parsed.name && candidate.port === parsed.port;
405
+ });
406
+ if (!match) throw new OffloadRefusal(selected, `not listed in offload.machines (configured: ${entries.join(", ") || "none"})`);
407
+ return match;
408
+ }
409
+ function resolveBuildPlacement(flag) {
410
+ const offload = loadConfig$1()?.offload;
411
+ let selected = "auto";
412
+ try {
413
+ selected = resolveBuildMachine(flag, process.env.STIM_OFFLOAD_MACHINE, offload?.machine);
414
+ selected = requireConfiguredMachine(selected, offload?.machines);
415
+ if (namedBuildMachine(selected) && pairedMachines([selected]).length === 0) throw new OffloadRefusal(selected, "no build machine is paired");
416
+ return { selected };
417
+ } catch (error) {
418
+ return {
419
+ selected,
420
+ failure: {
421
+ code: error.code,
422
+ message: error.message,
423
+ remedy: error instanceof OffloadRefusal ? error.remedy : "Pass --build-machine auto, local, or an entry from stim settings get offload.machines."
424
+ }
425
+ };
426
+ }
427
+ }
428
+ //#endregion
429
+ //#region src/offload/client.ts
430
+ const CONNECT_TIMEOUT_MS = 1e4;
431
+ const OFFER_TIMEOUT_MS = 2e4;
432
+ const REQUEST_TIMEOUT_MS = 12e4;
433
+ const JOB_TIMEOUT_MS = 39e5;
434
+ const PING_MS = 15e3;
435
+ const SILENT_MS = 6e4;
436
+ const CLOSE_TIMEOUT_MS = 2e3;
437
+ const CLOSE_ABNORMAL = 1006;
438
+ const TURNED_AWAY = /* @__PURE__ */ new Set([
439
+ "unauthorized",
440
+ "forbidden",
441
+ "approval-pending",
442
+ "protocol-unsupported"
443
+ ]);
444
+ const RESUME_WINDOW_MS = 18e4;
445
+ const RESUME_DELAY_MS = 2e3;
446
+ const RESUME_MAX_DELAY_MS = 15e3;
447
+ const PAGE_BYTES = 49152;
448
+ const CHUNK_BYTES = 61440;
449
+ const MAX_BUFFERED = 8388608;
450
+ const DIGEST_BYTES = 32;
451
+ function offloadMode(env = process.env) {
452
+ const raw = env.STIM_OFFLOAD_MODE || loadConfig$1()?.offload?.mode;
453
+ return OFFLOAD_MODES.includes(raw) ? raw : "auto";
454
+ }
455
+ function buildPlacementCandidates(selected) {
456
+ return {
457
+ mode: selected === "local" ? "off" : offloadMode(),
458
+ machines: selected === "local" ? [] : namedBuildMachine(selected) ? pairedMachines([selected]).slice(0, 1) : pairedMachines()
459
+ };
460
+ }
461
+ function remotePhaseText(name, msg, machine) {
462
+ const text = msg.trim();
463
+ return `${text.startsWith(`${name} `) ? text.slice(name.length).trimStart() : text} (on ${machine})`;
464
+ }
465
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
466
+ function capacityText(capacity) {
467
+ const builds = capacity.maxBuilds > 0 ? `${capacity.builds} of ${capacity.maxBuilds} build slots busy` : plural(capacity.builds, "build");
468
+ return `load ${capacity.loadPerCore}/core, ${builds}`;
469
+ }
470
+ function placementLoad(choice) {
471
+ const { loadPerCore, builds, maxBuilds } = choice.offer.capacity;
472
+ if (typeof loadPerCore !== "number" || typeof builds !== "number") return "";
473
+ return `; ${capacityText({
474
+ loadPerCore,
475
+ builds,
476
+ maxBuilds: maxBuilds ?? 0
477
+ })} there`;
478
+ }
479
+ /**
480
+ * Whether a build may go to a build machine at all, before any machine is asked. `auto` keeps the build here while
481
+ * this Mac has a free `concurrency.maxBuilds` slot and its load per core is under `offload.maxLoadPerCore`.
482
+ * A named selection ignores local capacity and mode, and refuses an unsupported build or missing pairing.
483
+ */
484
+ function offloadPlacement({ mode, machines, here, unsupported, selected = "auto" }) {
485
+ if (namedBuildMachine(selected)) {
486
+ if (unsupported) throw new OffloadRefusal(selected, `${unsupported}, so a named build machine cannot take it`);
487
+ if (machines === 0) throw new OffloadRefusal(selected, "no build machine is paired");
488
+ return {
489
+ offload: true,
490
+ reason: `selected with --build-machine ${selected}`
491
+ };
492
+ }
493
+ if (selected === "local") return {
494
+ offload: false,
495
+ reason: "--build-machine local"
496
+ };
497
+ if (mode === "off") return {
498
+ offload: false,
499
+ reason: "offload.mode is off"
500
+ };
501
+ if (machines === 0) return {
502
+ offload: false,
503
+ reason: "no build machine is paired"
504
+ };
505
+ if (unsupported) return {
506
+ offload: false,
507
+ reason: unsupported
508
+ };
509
+ if (mode === "force") return {
510
+ offload: true,
511
+ reason: "offload.mode is force"
512
+ };
513
+ const busy = saturation(here);
514
+ return busy ? {
515
+ offload: true,
516
+ reason: `this Mac is busy: ${busy} (${capacityText(here)})`
517
+ } : {
518
+ offload: false,
519
+ reason: `${capacityText(here)} here`
520
+ };
521
+ }
522
+ const gb = (bytes) => (bytes / 1024 ** 3).toFixed(1);
523
+ /** Every reason a machine's offer cannot take this build, in either mode; empty when it can. */
524
+ function offerProblems(offer, target) {
525
+ const problems = toolchainMismatches(target, offer.toolchain);
526
+ const capacity = offer.capacity;
527
+ const { running, max, diskFreeBytes, minDiskFreeBytes, declined } = capacity;
528
+ if (diskFreeBytes !== null && diskFreeBytes < minDiskFreeBytes) problems.push({
529
+ code: "disk",
530
+ reason: `${gb(diskFreeBytes)} GB free, needs ${gb(minDiskFreeBytes)} GB`
531
+ });
532
+ else if (declined || declined === void 0 && running >= max) {
533
+ const why = declined ?? `already running ${running} offloaded build(s), its limit`;
534
+ const load = typeof capacity.loadPerCore === "number" && typeof capacity.builds === "number" ? `; ${capacityText({
535
+ loadPerCore: capacity.loadPerCore,
536
+ builds: capacity.builds,
537
+ maxBuilds: capacity.maxBuilds ?? 0
538
+ })}` : "";
539
+ problems.push({
540
+ code: "busy",
541
+ reason: `busy (${why}${load})`
542
+ });
543
+ }
544
+ return problems;
545
+ }
546
+ /**
547
+ * The asked machines that can take the build, best first, and one reason per machine that cannot. A machine with any
548
+ * `offerProblems` never takes it. In `auto`, a machine must also be expected to build faster than this Mac: while
549
+ * every local build slot is busy any machine that accepts will do, otherwise its load per core must be lower than
550
+ * this Mac's. A named selection considers only that machine and skips this local-capacity gating.
551
+ * A machine that reports no load (a stim-server older than capacity) takes an `auto` build only while
552
+ * every local slot is busy. The rest rank warmest first, then least loaded.
553
+ */
554
+ function pickOffer({ mode, here, offers, target, selected = "auto" }) {
555
+ const slotsFull = here.maxBuilds > 0 && here.builds >= here.maxBuilds;
556
+ const reasons = [];
557
+ const ranked = [];
558
+ offers.forEach(({ machine, offer, failure }, index) => {
559
+ if (namedBuildMachine(selected) && machine !== selected) return;
560
+ if (!offer) return void reasons.push(`${machine}: ${failure ?? "no offer"}`);
561
+ const problems = offerProblems(offer, target);
562
+ if (problems.length) {
563
+ reasons.push(`${machine}: ${problems.map((problem) => problem.reason).join("; ")}`);
564
+ return;
565
+ }
566
+ const load = offer.capacity.loadPerCore;
567
+ if (selected === "auto" && mode === "auto" && !slotsFull) {
568
+ if (typeof load !== "number") {
569
+ reasons.push(`${machine}: capacity unknown (older stim-server) while this Mac has a free slot`);
570
+ return;
571
+ }
572
+ if (load >= here.loadPerCore) {
573
+ reasons.push(`${machine}: no less loaded (load ${load}/core there, ${here.loadPerCore}/core here)`);
574
+ return;
575
+ }
576
+ }
577
+ const score = Number(offer.warm.checkout) + Number(offer.warm.dependencies) + Number(offer.warm.build);
578
+ ranked.push({
579
+ index,
580
+ score,
581
+ load: typeof load === "number" ? load : Number.POSITIVE_INFINITY
582
+ });
583
+ });
584
+ ranked.sort((a, b) => b.score - a.score || a.load - b.load);
585
+ return {
586
+ order: ranked.map((each) => each.index),
587
+ reasons
588
+ };
589
+ }
590
+ const openSockets = /* @__PURE__ */ new Set();
591
+ let closingOnExit = false;
592
+ /**
593
+ * A close frame on every connection still open when this process exits, as on an interrupt, so the machine cancels
594
+ * its builds instead of keeping them for a new connection.
595
+ */
596
+ function closeOnExit(socket) {
597
+ if (!closingOnExit) {
598
+ closingOnExit = true;
599
+ process.once("exit", () => {
600
+ for (const each of openSockets) each.close(1e3);
601
+ });
602
+ }
603
+ openSockets.add(socket);
604
+ socket.once("close", () => openSockets.delete(socket));
605
+ }
606
+ /**
607
+ * One authenticated connection to another Mac's stim-server, for build offload or device hosting. Closing it cancels
608
+ * its build jobs there; a connection that drops, or that stays silent for `SILENT_MS` after `watch()`, leaves them
609
+ * running for a while so a new connection can `build.attach` to them.
610
+ */
611
+ var BuildConnection = class BuildConnection {
612
+ nextId = 2;
613
+ pending = /* @__PURE__ */ new Map();
614
+ progress = null;
615
+ binary = null;
616
+ closed = null;
617
+ heard = Date.now();
618
+ watched = false;
619
+ ended = false;
620
+ keepalive;
621
+ socket;
622
+ features;
623
+ constructor(socket, features) {
624
+ this.socket = socket;
625
+ this.features = features;
626
+ closeOnExit(socket);
627
+ this.keepalive = setInterval(() => {
628
+ if (this.watched && Date.now() - this.heard > SILENT_MS) return void socket.terminate();
629
+ socket.ping();
630
+ }, PING_MS);
631
+ this.keepalive.unref();
632
+ socket.on("pong", () => this.heard = Date.now());
633
+ socket.on("message", (data, isBinary) => {
634
+ this.heard = Date.now();
635
+ const frame = Buffer.isBuffer(data) ? data : Buffer.concat(Array.isArray(data) ? data : [Buffer.from(data)]);
636
+ if (isBinary) return this.binary?.(frame);
637
+ let message;
638
+ try {
639
+ message = JSON.parse(frame.toString("utf8"));
640
+ } catch {
641
+ return;
642
+ }
643
+ if (!isJsonObject(message)) return;
644
+ if (message.event === "build.progress") return this.progress?.(message);
645
+ const resolve = typeof message.id === "number" ? this.pending.get(message.id) : void 0;
646
+ if (resolve) {
647
+ this.pending.delete(message.id);
648
+ resolve(message);
649
+ }
650
+ });
651
+ socket.on("close", (code, reason) => {
652
+ clearInterval(this.keepalive);
653
+ this.closed = `the connection closed (${code}${reason.length ? ` ${reason.toString()}` : ""})`;
654
+ for (const resolve of this.pending.values()) resolve({ error: {
655
+ code: "closed",
656
+ message: this.closed
657
+ } });
658
+ this.pending.clear();
659
+ const dropped = code === CLOSE_ABNORMAL;
660
+ this.progress?.({
661
+ job: "",
662
+ outcome: {
663
+ ok: false,
664
+ code: dropped ? "dropped" : "closed",
665
+ message: this.closed
666
+ }
667
+ });
668
+ });
669
+ socket.on("error", () => {});
670
+ }
671
+ /** Resolves why it could not connect; `refused` when the machine answered and turned this Mac away. */
672
+ static open(target, token, timeoutMs, capability = "build") {
673
+ return new Promise((resolve) => {
674
+ const options = {
675
+ handshakeTimeout: timeoutMs,
676
+ servername: target.servername,
677
+ headers: { Host: target.host }
678
+ };
679
+ const socket = new WebSocket(target.url, options);
680
+ const fail = (reason, refused = false, code) => {
681
+ clearTimeout(timer);
682
+ socket.removeAllListeners();
683
+ socket.on("error", () => {});
684
+ socket.terminate();
685
+ resolve({
686
+ failure: reason,
687
+ refused,
688
+ ...code ? { code } : {}
689
+ });
690
+ };
691
+ const timer = setTimeout(() => fail("no reply in time"), timeoutMs);
692
+ socket.once("error", (error) => fail(/Unexpected server response: 502/.test(error.message) ? "no stim-server answers behind its tailscale serve route (HTTP 502)" : error.message));
693
+ socket.once("close", () => fail("the connection closed before hello"));
694
+ socket.once("open", () => {
695
+ socket.send(JSON.stringify({
696
+ id: 1,
697
+ method: "hello",
698
+ params: {
699
+ protocol: 1,
700
+ client: {
701
+ name: "stim",
702
+ version: "1"
703
+ },
704
+ auth: { deviceToken: token }
705
+ }
706
+ }));
707
+ });
708
+ socket.once("message", (data) => {
709
+ clearTimeout(timer);
710
+ socket.removeAllListeners();
711
+ let reply;
712
+ try {
713
+ reply = JSON.parse(String(data));
714
+ } catch {
715
+ return fail("the reply was not a hello result");
716
+ }
717
+ if (isJsonObject(reply) && isJsonObject(reply.error)) return fail(String(reply.error.message), TURNED_AWAY.has(String(reply.error.code)), String(reply.error.code));
718
+ const capabilities = isJsonObject(reply) && isJsonObject(reply.result) ? reply.result.capabilities : null;
719
+ if (!Array.isArray(capabilities) || !capabilities.includes(capability)) return fail(`it has not granted this Mac ${capability === "build" ? "build" : "hosting"} access`, true, "forbidden");
720
+ const features = isJsonObject(reply) && isJsonObject(reply.result) ? reply.result.features : null;
721
+ resolve(new BuildConnection(socket, Array.isArray(features) ? features.filter((each) => typeof each === "string") : []));
722
+ });
723
+ });
724
+ }
725
+ supports(feature) {
726
+ return this.features.includes(feature);
727
+ }
728
+ request(method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
729
+ if (this.closed) return Promise.resolve({ error: {
730
+ code: "closed",
731
+ message: this.closed
732
+ } });
733
+ const id = this.nextId++;
734
+ return new Promise((resolve) => {
735
+ const timer = setTimeout(() => {
736
+ this.pending.delete(id);
737
+ resolve({ error: {
738
+ code: "timeout",
739
+ message: `${method} got no reply in ${timeoutMs / 1e3} s`
740
+ } });
741
+ }, timeoutMs);
742
+ this.pending.set(id, (reply) => {
743
+ clearTimeout(timer);
744
+ resolve(reply);
745
+ });
746
+ this.socket.send(JSON.stringify({
747
+ id,
748
+ method,
749
+ params
750
+ }));
751
+ });
752
+ }
753
+ async sendBinary(frame) {
754
+ this.socket.send(frame, { binary: true });
755
+ while (this.socket.bufferedAmount > MAX_BUFFERED && !this.closed) await new Promise((resolve) => setTimeout(resolve, 5));
756
+ }
757
+ onProgress(listener) {
758
+ this.progress = listener;
759
+ }
760
+ onBinary(listener) {
761
+ this.binary = listener;
762
+ }
763
+ get failure() {
764
+ return this.closed;
765
+ }
766
+ /**
767
+ * Treats `SILENT_MS` without a frame as a dropped connection from now on. Only while waiting on a job: a long
768
+ * upload or this process hashing the checkout can delay pongs without anything being wrong.
769
+ */
770
+ watch() {
771
+ this.heard = Date.now();
772
+ this.watched = true;
773
+ }
774
+ /** Closes with a close frame, which tells the machine to cancel this connection's jobs. */
775
+ close() {
776
+ if (this.ended) return;
777
+ this.forget();
778
+ if (this.socket.readyState === WebSocket.CLOSED) return;
779
+ const timer = setTimeout(() => this.socket.terminate(), CLOSE_TIMEOUT_MS);
780
+ this.socket.once("close", () => clearTimeout(timer));
781
+ this.socket.close(1e3);
782
+ }
783
+ /** Ends the connection without a close frame, which leaves its jobs on the machine for a new connection. */
784
+ drop() {
785
+ if (this.ended) return;
786
+ this.forget();
787
+ this.socket.terminate();
788
+ }
789
+ forget() {
790
+ this.ended = true;
791
+ clearInterval(this.keepalive);
792
+ this.socket.removeAllListeners("close");
793
+ openSockets.delete(this.socket);
794
+ }
795
+ };
796
+ function replyError(reply) {
797
+ return "error" in reply ? `${reply.error.code}: ${reply.error.message}` : null;
798
+ }
799
+ /** The repository on the build machine: one area per git common dir, shared by all its worktrees. */
800
+ function repoIdentity(projectRoot) {
801
+ const run = getExecutor().runFile;
802
+ const repoRoot = realpathSync(run("git", [
803
+ "-C",
804
+ projectRoot,
805
+ "rev-parse",
806
+ "--show-toplevel"
807
+ ]));
808
+ const common = realpathSync(run("git", [
809
+ "-C",
810
+ repoRoot,
811
+ "rev-parse",
812
+ "--path-format=absolute",
813
+ "--git-common-dir"
814
+ ]));
815
+ const digest = createHash("sha256").update(common).digest("hex").slice(0, 12);
816
+ const name = (basename(dirname(common)) || "repo").replace(/[^A-Za-z0-9._-]/g, "_").replace(/^[^A-Za-z0-9]+/, "");
817
+ const lock = [
818
+ "pnpm-lock.yaml",
819
+ "yarn.lock",
820
+ "package-lock.json"
821
+ ].find((file) => existsSync(join(repoRoot, file)));
822
+ return {
823
+ repoRoot,
824
+ project: relative(repoRoot, realpathSync(projectRoot)),
825
+ repo: `${name.slice(0, 60) || "repo"}-${digest}`,
826
+ lockfile: lock ? createHash("sha256").update(readFileSync(join(repoRoot, lock))).digest("hex") : null
827
+ };
828
+ }
829
+ /** The exact source a build needs: tracked and untracked, not ignored, as `git ls-files -co --exclude-standard`. */
830
+ function sourceManifest(repoRoot) {
831
+ const listed = getExecutor().runFile("git", [
832
+ "-C",
833
+ repoRoot,
834
+ "ls-files",
835
+ "-z",
836
+ "-co",
837
+ "--exclude-standard"
838
+ ], {
839
+ untrimmed: true,
840
+ timeoutMs: 12e4
841
+ });
842
+ const files = [];
843
+ for (const path of new Set(listed.split("\0").filter(Boolean))) {
844
+ const absolute = join(repoRoot, path);
845
+ let stat;
846
+ try {
847
+ stat = lstatSync(absolute);
848
+ } catch {
849
+ continue;
850
+ }
851
+ if (stat.isSymbolicLink()) {
852
+ const target = Buffer.from(readlinkSync(absolute));
853
+ files.push({
854
+ path,
855
+ kind: "link",
856
+ size: target.length,
857
+ sha256: sha256(target)
858
+ });
859
+ } else if (stat.isFile()) {
860
+ const content = readFileSync(absolute);
861
+ files.push({
862
+ path,
863
+ kind: stat.mode & 73 ? "exec" : "file",
864
+ size: content.length,
865
+ sha256: sha256(content)
866
+ });
867
+ }
868
+ }
869
+ return files;
870
+ }
871
+ const sha256 = (content) => createHash("sha256").update(content).digest("hex");
872
+ function blobContent(repoRoot, file) {
873
+ const absolute = join(repoRoot, file.path);
874
+ return file.kind === "link" ? Buffer.from(readlinkSync(absolute)) : readFileSync(absolute);
875
+ }
876
+ /** Closes every connection the choice still holds. */
877
+ function closeOffload(choice) {
878
+ choice.connection.close();
879
+ for (const each of choice.runnersUp.splice(0)) each.connection.close();
880
+ }
881
+ /** Connects to one paired machine and asks it for an offer; the caller closes the connection it returns. */
882
+ async function probeMachine(credential, identity, { connectMs = CONNECT_TIMEOUT_MS, offerMs = OFFER_TIMEOUT_MS } = {}) {
883
+ const target = pinnedEndpoint$1(credential);
884
+ if (typeof target === "string") return {
885
+ credential,
886
+ failure: target
887
+ };
888
+ const connection = await BuildConnection.open(target, credential.deviceToken, connectMs);
889
+ if (!(connection instanceof BuildConnection)) return {
890
+ credential,
891
+ failure: connection.failure
892
+ };
893
+ const reply = await connection.request("build.offer", {
894
+ repo: identity.repo,
895
+ ...identity.lockfile ? { lockfile: identity.lockfile } : {}
896
+ }, offerMs);
897
+ const failure = replyError(reply);
898
+ if (failure || !("result" in reply)) {
899
+ connection.close();
900
+ return {
901
+ credential,
902
+ failure: failure ?? "no offer"
903
+ };
904
+ }
905
+ return {
906
+ credential,
907
+ connection,
908
+ offer: reply.result
909
+ };
910
+ }
911
+ /**
912
+ * Asks every paired machine for an offer in parallel and keeps the connection to the one `pickOffer` chooses, or
913
+ * returns why none takes the build. A named selection probes only its credential and skips auto load gating.
914
+ */
915
+ async function chooseBuildMachine({ projectRoot, target, mode, here, note, machines = pairedMachines(), selected = "auto" }) {
916
+ const started = Date.now();
917
+ let identity;
918
+ try {
919
+ identity = repoIdentity(projectRoot);
920
+ } catch (error) {
921
+ return `this app is not in a git checkout (${error.message.split("\n")[0]})`;
922
+ }
923
+ if (namedBuildMachine(selected)) machines = machines.filter((each) => each.machine === selected).slice(0, 1);
924
+ const asked = await Promise.all(machines.map((credential) => probeMachine(credential, identity)));
925
+ const { order, reasons } = pickOffer({
926
+ selected,
927
+ mode,
928
+ here,
929
+ target,
930
+ offers: asked.map((each) => ({
931
+ machine: each.credential.machine,
932
+ offer: "offer" in each ? each.offer : null,
933
+ ..."failure" in each ? { failure: each.failure } : {}
934
+ }))
935
+ });
936
+ asked.forEach((each, at) => {
937
+ if (!order.includes(at) && "connection" in each) each.connection.close();
938
+ });
939
+ if (order.length === 0) return reasons.length ? reasons.join("; ") : "no build machine is paired";
940
+ for (const reason of reasons) note(reason);
941
+ const [first, ...rest] = order.map((at) => {
942
+ const pick = asked[at];
943
+ return {
944
+ machine: pick.credential.machine,
945
+ credential: pick.credential,
946
+ connection: pick.connection,
947
+ offer: pick.offer
948
+ };
949
+ });
950
+ return {
951
+ ...first,
952
+ target,
953
+ offerMs: Date.now() - started,
954
+ identity,
955
+ runnersUp: rest
956
+ };
957
+ }
958
+ const ARTIFACT_NAME = {
959
+ ios: /^[^/]+\.app$/,
960
+ android: /^[^/]+\.apk$/,
961
+ macos: /^[^/]+\.app$/
962
+ };
963
+ /**
964
+ * Reconnects to the machine that runs `job` and takes the job over with `build.attach`, retrying until
965
+ * `RESUME_WINDOW_MS`, shorter than the machine's grace for a dropped connection, has passed. Returns why it
966
+ * could not.
967
+ */
968
+ async function resumeJob(credential, job, abandoned) {
969
+ const deadline = Date.now() + RESUME_WINDOW_MS;
970
+ let delay = RESUME_DELAY_MS;
971
+ let last = "no attempt";
972
+ while (!abandoned()) {
973
+ const target = pinnedEndpoint$1(credential);
974
+ const connection = typeof target === "string" ? {
975
+ failure: target,
976
+ refused: false
977
+ } : await BuildConnection.open(target, credential.deviceToken, CONNECT_TIMEOUT_MS);
978
+ if (!(connection instanceof BuildConnection)) {
979
+ if (connection.refused) return `the machine turned this Mac away (${connection.failure})`;
980
+ last = connection.failure;
981
+ } else {
982
+ const early = [];
983
+ connection.onProgress((event) => early.push(event));
984
+ const reply = await connection.request("build.attach", { job }, OFFER_TIMEOUT_MS);
985
+ if ("result" in reply) {
986
+ const outcome = isJsonObject(reply.result) ? reply.result.outcome : null;
987
+ return {
988
+ connection,
989
+ outcome: isJsonObject(outcome) ? outcome : null,
990
+ early
991
+ };
992
+ }
993
+ last = replyError(reply);
994
+ if (reply.error.code !== "closed" && reply.error.code !== "timeout") {
995
+ connection.close();
996
+ return `the machine did not hand the build back (${last})`;
997
+ }
998
+ connection.drop();
999
+ }
1000
+ if (Date.now() + delay > deadline) break;
1001
+ await new Promise((resolve) => setTimeout(resolve, delay));
1002
+ delay = Math.min(delay * 2, RESUME_MAX_DELAY_MS);
1003
+ }
1004
+ return `no connection to it within ${RESUME_WINDOW_MS / 6e4} min (${last})`;
1005
+ }
1006
+ /** Mirrors the checkout on the machine: the manifest in pages, then every blob it lacks. */
1007
+ async function syncSource(connection, identity, onEnter) {
1008
+ onEnter("sync");
1009
+ const syncStarted = Date.now();
1010
+ const manifest = sourceManifest(identity.repoRoot);
1011
+ const bySha = new Map(manifest.map((file) => [file.sha256, file]));
1012
+ const missing = [];
1013
+ for (let index = 0; index < manifest.length || index === 0;) {
1014
+ const page = [];
1015
+ let bytes = 0;
1016
+ while (index < manifest.length && (page.length === 0 || bytes < PAGE_BYTES)) {
1017
+ const file = manifest[index++];
1018
+ bytes += Buffer.byteLength(JSON.stringify(file)) + 1;
1019
+ page.push(file);
1020
+ }
1021
+ const done = index >= manifest.length;
1022
+ const reply = await connection.request("build.sync", {
1023
+ repo: identity.repo,
1024
+ files: page,
1025
+ done
1026
+ });
1027
+ const failure = replyError(reply);
1028
+ if (failure || !("result" in reply)) return { failure: `sync: ${failure ?? "no reply"}` };
1029
+ missing.push(...reply.result.missing ?? []);
1030
+ if (done) break;
1031
+ }
1032
+ let uploadedBytes = 0;
1033
+ for (const digest of missing) {
1034
+ const file = bySha.get(digest);
1035
+ if (!file) return { failure: `sync: the machine asked for an unknown blob ${digest}` };
1036
+ const content = blobContent(identity.repoRoot, file);
1037
+ if (sha256(content) !== digest) return { failure: `sync: ${file.path} changed while syncing` };
1038
+ const header = Buffer.from(digest, "hex");
1039
+ for (let offset = 0; offset < content.length || offset === 0; offset += CHUNK_BYTES) {
1040
+ await connection.sendBinary(Buffer.concat([header, content.subarray(offset, offset + CHUNK_BYTES)]));
1041
+ if (content.length === 0) break;
1042
+ }
1043
+ uploadedBytes += content.length;
1044
+ if (connection.failure) return { failure: `sync: ${connection.failure}` };
1045
+ }
1046
+ return {
1047
+ files: manifest.length,
1048
+ uploaded: missing.length,
1049
+ uploadedBytes,
1050
+ syncMs: Date.now() - syncStarted,
1051
+ digest: manifestDigest(manifest)
1052
+ };
1053
+ }
1054
+ /**
1055
+ * Builds on the chosen machine and brings the `.app` or `.apk` back into `stagingDir`, verified against the
1056
+ * sha256 the machine reports. The caller re-fingerprints and stores it.
1057
+ */
1058
+ async function offloadBuild({ choice, expectedFingerprint, request, stagingDir, onPhase, onEnter, onRecord, note }) {
1059
+ const { identity } = choice;
1060
+ const started = Date.now();
1061
+ const fail = (reason) => {
1062
+ closeOffload(choice);
1063
+ return {
1064
+ ok: false,
1065
+ machine: choice.machine,
1066
+ reason: reason.split("\n")[0].slice(0, 300)
1067
+ };
1068
+ };
1069
+ try {
1070
+ let job = null;
1071
+ let early = [];
1072
+ let settle;
1073
+ let settled = false;
1074
+ const outcome = new Promise((resolve) => {
1075
+ settle = (value) => {
1076
+ settled = true;
1077
+ resolve(value);
1078
+ };
1079
+ });
1080
+ let resuming = false;
1081
+ const reattach = async (why) => {
1082
+ if (resuming || settled) return;
1083
+ resuming = true;
1084
+ note(`offload: the connection to ${choice.machine} dropped (${why}); reattaching to the build there`);
1085
+ const resumed = await resumeJob(choice.credential, job, () => settled);
1086
+ resuming = false;
1087
+ if (settled) {
1088
+ if (typeof resumed !== "string") resumed.connection.close();
1089
+ return;
1090
+ }
1091
+ if (typeof resumed === "string") return settle({
1092
+ ok: false,
1093
+ code: "closed",
1094
+ message: `${why}; ${resumed}`
1095
+ });
1096
+ choice.connection = resumed.connection;
1097
+ resumed.connection.watch();
1098
+ note(`offload: reattached to the build on ${choice.machine}`);
1099
+ resumed.connection.onProgress(handle);
1100
+ if (resumed.outcome) settle(resumed.outcome);
1101
+ for (const event of resumed.early.splice(0)) handle(event);
1102
+ };
1103
+ const handle = (event) => {
1104
+ if (event.job && event.job !== job) return;
1105
+ if (event.outcome?.code === "dropped" && !event.job && job !== null) {
1106
+ reattach(String(event.outcome.message));
1107
+ return;
1108
+ }
1109
+ if (event.outcome) return settle(event.outcome);
1110
+ if (event.record) onRecord(event.record);
1111
+ if (event.phase && typeof event.msg === "string") {
1112
+ onEnter(event.phase);
1113
+ onPhase(event.phase, event.msg);
1114
+ }
1115
+ };
1116
+ let syncMs = 0;
1117
+ let uploadedBytes = 0;
1118
+ let workerStarted = 0;
1119
+ const moveOn = (why) => {
1120
+ const next = choice.runnersUp.shift();
1121
+ if (!next) return false;
1122
+ note(`placement: ${next.machine} (${choice.machine} could not take the build: ${why})`);
1123
+ choice.connection.close();
1124
+ Object.assign(choice, next);
1125
+ return true;
1126
+ };
1127
+ for (;;) {
1128
+ const synced = await syncSource(choice.connection, identity, onEnter);
1129
+ if ("failure" in synced) {
1130
+ if (moveOn(synced.failure)) continue;
1131
+ return fail(synced.failure);
1132
+ }
1133
+ ({syncMs, uploadedBytes} = synced);
1134
+ onPhase("sync", `${synced.files} files, uploaded ${synced.uploaded} (${mb(uploadedBytes)}) in ${seconds(syncMs)}`);
1135
+ workerStarted = Date.now();
1136
+ early = [];
1137
+ choice.connection.onProgress((event) => job === null ? early.push(event) : handle(event));
1138
+ const reply = await choice.connection.request("build.start", {
1139
+ repo: identity.repo,
1140
+ project: identity.project,
1141
+ platform: request.platform,
1142
+ fingerprint: request.platform === "macos" ? synced.digest : expectedFingerprint,
1143
+ ...request.platform === "macos" ? { macos: {
1144
+ product: request.product,
1145
+ infoPlist: request.infoPlist,
1146
+ bundleId: request.bundleId,
1147
+ ...request.resources !== void 0 ? { resources: request.resources } : {},
1148
+ ...request.assetCatalog !== void 0 ? { assetCatalog: request.assetCatalog } : {}
1149
+ } } : {
1150
+ configuration: request.platform === "ios" ? request.configuration : null,
1151
+ scheme: request.platform === "ios" ? request.scheme : null,
1152
+ runtime: request.platform === "ios" ? request.runtime : null,
1153
+ packageName: packageName(join(identity.repoRoot, identity.project)),
1154
+ isExpo: request.isExpo,
1155
+ optimizations: request.platform === "ios" && isJsonObject(request.optimizations) ? request.optimizations : null,
1156
+ ...request.platform === "android" ? { android: request.android } : {}
1157
+ },
1158
+ stimBuild: choice.target.local.stimBuild
1159
+ });
1160
+ if ("result" in reply) {
1161
+ job = reply.result.job;
1162
+ break;
1163
+ }
1164
+ const refused = `start: ${replyError(reply)}`;
1165
+ if (reply.error.code === "bad-request" || !moveOn(refused)) return fail(refused);
1166
+ }
1167
+ for (const each of choice.runnersUp.splice(0)) each.connection.close();
1168
+ choice.connection.watch();
1169
+ for (const event of early.splice(0)) handle(event);
1170
+ const timer = setTimeout(() => settle({
1171
+ ok: false,
1172
+ code: "timeout",
1173
+ message: "the build did not finish in time"
1174
+ }), JOB_TIMEOUT_MS);
1175
+ const result = await outcome;
1176
+ clearTimeout(timer);
1177
+ const { connection, machine } = choice;
1178
+ connection.onProgress(null);
1179
+ const workerMs = Date.now() - workerStarted;
1180
+ if (result.ok !== true) return fail(`${String(result.code ?? "failed")}: ${String(result.message ?? "")}`);
1181
+ const artifact = result.artifact;
1182
+ const name = typeof artifact?.name === "string" ? artifact.name : "";
1183
+ if (!ARTIFACT_NAME[request.platform].test(name) || typeof artifact.sha256 !== "string") return fail(`the machine reported no .${request.platform === "android" ? "apk" : "app"} artifact`);
1184
+ onEnter("fetch");
1185
+ const fetchStarted = Date.now();
1186
+ rmSync(stagingDir, {
1187
+ recursive: true,
1188
+ force: true
1189
+ });
1190
+ mkdirSync(stagingDir, { recursive: true });
1191
+ const archive = join(stagingDir, "app.tgz");
1192
+ const fd = openSync(archive, "w");
1193
+ const hash = createHash("sha256");
1194
+ let received = 0;
1195
+ let badFrame = false;
1196
+ connection.onBinary((frame) => {
1197
+ if (frame.length < DIGEST_BYTES || frame.subarray(0, DIGEST_BYTES).toString("hex") !== artifact.sha256) {
1198
+ badFrame = true;
1199
+ return;
1200
+ }
1201
+ const data = frame.subarray(DIGEST_BYTES);
1202
+ try {
1203
+ writeSync(fd, data);
1204
+ } catch {
1205
+ badFrame = true;
1206
+ return;
1207
+ }
1208
+ hash.update(data);
1209
+ received += data.length;
1210
+ });
1211
+ const fetched = await connection.request("build.artifact", { job }, 9e5);
1212
+ connection.onBinary(null);
1213
+ closeSync(fd);
1214
+ const fetchFailure = replyError(fetched);
1215
+ if (fetchFailure || !("result" in fetched)) return fail(`fetch: ${fetchFailure ?? "no reply"}`);
1216
+ const digest = hash.digest("hex");
1217
+ const declared = fetched.result;
1218
+ if (badFrame) return fail("fetch: an artifact frame had another digest or could not be written here");
1219
+ if (digest !== artifact.sha256 || declared.sha256 !== digest || declared.size !== received) return fail(`fetch: the artifact's sha256 ${digest.slice(0, 12)} does not match ${String(artifact.sha256).slice(0, 12)}`);
1220
+ await getExecutor().runFileAsync("tar", [
1221
+ "-xf",
1222
+ archive,
1223
+ "-C",
1224
+ stagingDir
1225
+ ], { timeoutMs: 6e5 });
1226
+ rmSync(archive, { force: true });
1227
+ const artifactPath = join(stagingDir, name);
1228
+ if (request.platform === "ios" && !existsSync(join(artifactPath, "Info.plist"))) return fail(`fetch: ${name} has no Info.plist`);
1229
+ if (request.platform === "android" && !(lstatSync(artifactPath, { throwIfNoEntry: false })?.isFile() ?? false)) return fail(`fetch: ${name} is not a file`);
1230
+ if (request.platform === "macos") {
1231
+ for (const segments of [["Contents", "Info.plist"], [
1232
+ "Contents",
1233
+ "MacOS",
1234
+ request.product
1235
+ ]]) if (!lstatSync(join(artifactPath, ...segments), { throwIfNoEntry: false })?.isFile()) return fail(`fetch: ${name} has no regular ${segments.join("/")}`);
1236
+ }
1237
+ const fetchMs = Date.now() - fetchStarted;
1238
+ connection.close();
1239
+ return {
1240
+ ok: true,
1241
+ machine,
1242
+ artifactPath,
1243
+ ...request.platform === "macos" && typeof declared.handoff === "string" && /^[0-9a-f]{64}$/.test(declared.handoff) ? { handoff: {
1244
+ nodeId: choice.credential.nodeId,
1245
+ token: declared.handoff,
1246
+ sha256: digest
1247
+ } } : {},
1248
+ compilationCache: request.platform === "ios" ? compilationActivity(result.compilationCache) : COMPILATION_CACHE_UNAVAILABLE,
1249
+ ccache: request.platform === "android" ? ccacheActivity(result.compilationCache) : CCACHE_UNAVAILABLE,
1250
+ timings: {
1251
+ offerMs: choice.offerMs,
1252
+ syncMs,
1253
+ workerMs,
1254
+ fetchMs,
1255
+ totalMs: Date.now() - started + choice.offerMs,
1256
+ worker: isJsonObject(result.timings) ? result.timings : {},
1257
+ uploadedBytes,
1258
+ artifactBytes: received
1259
+ }
1260
+ };
1261
+ } catch (error) {
1262
+ return fail(error.message.split("\n")[0] ?? String(error));
1263
+ }
1264
+ }
1265
+ function packageName(projectRoot) {
1266
+ try {
1267
+ const name = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8")).name;
1268
+ return typeof name === "string" ? name : null;
1269
+ } catch {
1270
+ return null;
1271
+ }
1272
+ }
1273
+ const numberOrNull = (field) => typeof field === "number" ? field : null;
1274
+ function compilationActivity(value) {
1275
+ if (!isJsonObject(value) || value.status !== "reported") return COMPILATION_CACHE_UNAVAILABLE;
1276
+ return {
1277
+ status: "reported",
1278
+ hits: numberOrNull(value.hits),
1279
+ cacheableTasks: numberOrNull(value.cacheableTasks),
1280
+ hitRatePercent: numberOrNull(value.hitRatePercent)
1281
+ };
1282
+ }
1283
+ function ccacheActivity(value) {
1284
+ if (!isJsonObject(value) || value.status !== "reported") return CCACHE_UNAVAILABLE;
1285
+ return {
1286
+ status: "reported",
1287
+ hits: numberOrNull(value.hits),
1288
+ misses: numberOrNull(value.misses),
1289
+ hitRatePercent: numberOrNull(value.hitRatePercent)
1290
+ };
1291
+ }
1292
+ const mb = (bytes) => `${(bytes / 1024 ** 2).toFixed(1)} MB`;
1293
+ const seconds = (ms) => `${(ms / 1e3).toFixed(1)}s`;
1294
+ function simulatorRuntime(udid) {
1295
+ try {
1296
+ const listed = JSON.parse(getExecutor().runFileQuiet("xcrun", [
1297
+ "simctl",
1298
+ "list",
1299
+ "devices",
1300
+ "-j"
1301
+ ], { timeoutMs: 2e4 }) ?? "{}");
1302
+ for (const [runtime, devices] of Object.entries(listed.devices ?? {})) if (devices.some((device) => device.udid === udid)) return runtime;
1303
+ } catch {}
1304
+ return null;
1305
+ }
1306
+ const DOCTOR_CONNECT_MS = 5e3;
1307
+ const DOCTOR_OFFER_MS = 8e3;
1308
+ const PLATFORM_LABEL = {
1309
+ ios: "iOS",
1310
+ android: "Android",
1311
+ macos: "macOS"
1312
+ };
1313
+ const SHARED_PROBLEMS = /* @__PURE__ */ new Set([
1314
+ "stim-build",
1315
+ "arch",
1316
+ "disk",
1317
+ "busy"
1318
+ ]);
1319
+ /**
1320
+ * Doctor's offload check for `projectRoot`: one bounded offer per machine, judged for each build `targets` returns
1321
+ * by `offerProblems` exactly as placement judges it. No build runs. With several targets, a problem that only
1322
+ * one platform has names it.
1323
+ */
1324
+ function offloadCheck(projectRoot, targets) {
1325
+ let identity;
1326
+ try {
1327
+ identity = repoIdentity(projectRoot);
1328
+ } catch (error) {
1329
+ identity = error.message.split("\n")[0] ?? "git failed";
1330
+ }
1331
+ let resolved = null;
1332
+ return async (credential) => {
1333
+ if (typeof identity === "string") return {
1334
+ capacity: null,
1335
+ problems: [{
1336
+ code: "checkout",
1337
+ reason: `this app is not in a git checkout (${identity})`
1338
+ }]
1339
+ };
1340
+ const probe = await probeMachine(credential, identity, {
1341
+ connectMs: DOCTOR_CONNECT_MS,
1342
+ offerMs: DOCTOR_OFFER_MS
1343
+ });
1344
+ if ("failure" in probe) return {
1345
+ capacity: null,
1346
+ problems: [{
1347
+ code: "unreachable",
1348
+ reason: probe.failure
1349
+ }]
1350
+ };
1351
+ probe.connection.close();
1352
+ resolved ??= targets();
1353
+ const problems = [];
1354
+ for (const target of resolved) for (const problem of offerProblems(probe.offer, target)) {
1355
+ const labeled = resolved.length > 1 && !SHARED_PROBLEMS.has(problem.code) ? {
1356
+ ...problem,
1357
+ reason: `${PLATFORM_LABEL[target.platform]}: ${problem.reason}`
1358
+ } : problem;
1359
+ if (!problems.some((each) => each.code === labeled.code && each.reason === labeled.reason)) problems.push(labeled);
1360
+ }
1361
+ return {
1362
+ capacity: probe.offer.capacity,
1363
+ problems
1364
+ };
1365
+ };
1366
+ }
1367
+ //#endregion
1368
+ export { readHostPermissions as S, findPeer$1 as _, offloadBuild as a, realIo as b, placementLoad as c, OffloadRefusal as d, namedBuildMachine as f, endpoint as g, inspectBuildMachines as h, closeOffload as i, remotePhaseText as l, resolveBuildPlacement as m, buildPlacementCandidates as n, offloadCheck as o, parseBuildMachineOption as p, chooseBuildMachine as r, offloadPlacement as s, BuildConnection as t, simulatorRuntime as u, parseMachine$1 as v, readAccessTicket as x, pinnedEndpoint$1 as y };