@intentic/machine 1.325.0 → 1.327.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 (271) hide show
  1. package/README.md +387 -33
  2. package/dist/commands.d.ts.map +1 -1
  3. package/dist/commands.js +2 -0
  4. package/dist/commands.js.map +1 -1
  5. package/dist/daemon-base.d.ts.map +1 -1
  6. package/dist/daemon-base.js +2 -3
  7. package/dist/daemon-base.js.map +1 -1
  8. package/dist/device/audit.d.ts +2 -0
  9. package/dist/device/audit.d.ts.map +1 -1
  10. package/dist/device/audit.js +23 -3
  11. package/dist/device/audit.js.map +1 -1
  12. package/dist/device/commands.d.ts +4 -0
  13. package/dist/device/commands.d.ts.map +1 -1
  14. package/dist/device/commands.js +7 -2
  15. package/dist/device/commands.js.map +1 -1
  16. package/dist/device/connection.d.ts +12 -1
  17. package/dist/device/connection.d.ts.map +1 -1
  18. package/dist/device/connection.js +77 -3
  19. package/dist/device/connection.js.map +1 -1
  20. package/dist/device/grant.d.ts +52 -0
  21. package/dist/device/grant.d.ts.map +1 -1
  22. package/dist/device/loopback-catch.d.ts +1 -1
  23. package/dist/device/loopback-catch.d.ts.map +1 -1
  24. package/dist/device/loopback-catch.js +5 -30
  25. package/dist/device/loopback-catch.js.map +1 -1
  26. package/dist/device/mcp.d.ts.map +1 -1
  27. package/dist/device/mcp.js +190 -30
  28. package/dist/device/mcp.js.map +1 -1
  29. package/dist/device/router.d.ts +52 -0
  30. package/dist/device/router.d.ts.map +1 -1
  31. package/dist/device/router.js +21 -0
  32. package/dist/device/router.js.map +1 -1
  33. package/dist/device/sandbox-rounds/auto-backup.d.ts +3 -1
  34. package/dist/device/sandbox-rounds/auto-backup.d.ts.map +1 -1
  35. package/dist/device/sandbox-rounds/auto-backup.js +14 -5
  36. package/dist/device/sandbox-rounds/auto-backup.js.map +1 -1
  37. package/dist/device/sandbox-rounds/auto-prepare.d.ts +3 -1
  38. package/dist/device/sandbox-rounds/auto-prepare.d.ts.map +1 -1
  39. package/dist/device/sandbox-rounds/auto-prepare.js +3 -3
  40. package/dist/device/sandbox-rounds/auto-prepare.js.map +1 -1
  41. package/dist/device/sandbox-rounds/in-flight.d.ts.map +1 -0
  42. package/dist/device/sandbox-rounds/in-flight.js.map +1 -0
  43. package/dist/device/sandbox-rounds/keeper.d.ts +26 -4
  44. package/dist/device/sandbox-rounds/keeper.d.ts.map +1 -1
  45. package/dist/device/sandbox-rounds/keeper.js +129 -31
  46. package/dist/device/sandbox-rounds/keeper.js.map +1 -1
  47. package/dist/device/sandbox-rounds/probation-watch.d.ts.map +1 -1
  48. package/dist/device/sandbox-rounds/probation-watch.js.map +1 -1
  49. package/dist/device/tools/android-parse.d.ts +69 -0
  50. package/dist/device/tools/android-parse.d.ts.map +1 -0
  51. package/dist/device/tools/android-parse.js +391 -0
  52. package/dist/device/tools/android-parse.js.map +1 -0
  53. package/dist/device/tools/android.d.ts +71 -0
  54. package/dist/device/tools/android.d.ts.map +1 -0
  55. package/dist/device/tools/android.js +534 -0
  56. package/dist/device/tools/android.js.map +1 -0
  57. package/dist/device/tools/apps.d.ts +3 -3
  58. package/dist/device/tools/apps.d.ts.map +1 -1
  59. package/dist/device/tools/apps.js +16 -6
  60. package/dist/device/tools/apps.js.map +1 -1
  61. package/dist/device/tools/describe.d.ts.map +1 -1
  62. package/dist/device/tools/describe.js +10 -5
  63. package/dist/device/tools/describe.js.map +1 -1
  64. package/dist/device/tools/device.d.ts +9 -15
  65. package/dist/device/tools/device.d.ts.map +1 -1
  66. package/dist/device/tools/device.js +34 -58
  67. package/dist/device/tools/device.js.map +1 -1
  68. package/dist/device/tools/elements.d.ts +13 -0
  69. package/dist/device/tools/elements.d.ts.map +1 -0
  70. package/dist/device/tools/elements.js +84 -0
  71. package/dist/device/tools/elements.js.map +1 -0
  72. package/dist/device/tools/ic-binary.d.ts +1 -0
  73. package/dist/device/tools/ic-binary.d.ts.map +1 -1
  74. package/dist/device/tools/ic-binary.js +40 -17
  75. package/dist/device/tools/ic-binary.js.map +1 -1
  76. package/dist/device/tools/sandboxes.d.ts +1 -1
  77. package/dist/device/tools/sandboxes.d.ts.map +1 -1
  78. package/dist/device/tools/sandboxes.js +9 -8
  79. package/dist/device/tools/sandboxes.js.map +1 -1
  80. package/dist/device/tools/shell.js +1 -1
  81. package/dist/device/tools/shell.js.map +1 -1
  82. package/dist/device/tools/view.d.ts +4 -0
  83. package/dist/device/tools/view.d.ts.map +1 -0
  84. package/dist/device/tools/view.js +4 -0
  85. package/dist/device/tools/view.js.map +1 -0
  86. package/dist/environments/children.js +1 -1
  87. package/dist/environments/children.js.map +1 -1
  88. package/dist/environments/commands.js +1 -1
  89. package/dist/environments/commands.js.map +1 -1
  90. package/dist/environments/crossing.d.ts +1 -1
  91. package/dist/environments/crossing.d.ts.map +1 -1
  92. package/dist/environments/machine.js +1 -1
  93. package/dist/environments/machine.js.map +1 -1
  94. package/dist/environments/wsl.d.ts.map +1 -0
  95. package/dist/environments/wsl.js.map +1 -0
  96. package/dist/resident.d.ts.map +1 -1
  97. package/dist/resident.js +14 -8
  98. package/dist/resident.js.map +1 -1
  99. package/dist/status.d.ts.map +1 -1
  100. package/dist/status.js +16 -13
  101. package/dist/status.js.map +1 -1
  102. package/dist/sync/attach-commands.d.ts +39 -0
  103. package/dist/sync/attach-commands.d.ts.map +1 -0
  104. package/dist/sync/attach-commands.js +152 -0
  105. package/dist/sync/attach-commands.js.map +1 -0
  106. package/dist/sync/commands.d.ts +8 -4
  107. package/dist/sync/commands.d.ts.map +1 -1
  108. package/dist/sync/commands.js +137 -79
  109. package/dist/sync/commands.js.map +1 -1
  110. package/dist/sync/config.d.ts +43 -9
  111. package/dist/sync/config.d.ts.map +1 -1
  112. package/dist/sync/config.js +86 -41
  113. package/dist/sync/config.js.map +1 -1
  114. package/dist/sync/endpoint.d.ts +8 -1
  115. package/dist/sync/endpoint.d.ts.map +1 -1
  116. package/dist/sync/endpoint.js +41 -1
  117. package/dist/sync/endpoint.js.map +1 -1
  118. package/dist/sync/environment.d.ts +11 -0
  119. package/dist/sync/environment.d.ts.map +1 -0
  120. package/dist/sync/environment.js +48 -0
  121. package/dist/sync/environment.js.map +1 -0
  122. package/dist/sync/exec.d.ts +1 -0
  123. package/dist/sync/exec.d.ts.map +1 -1
  124. package/dist/sync/exec.js +4 -5
  125. package/dist/sync/exec.js.map +1 -1
  126. package/dist/sync/folders.d.ts +3 -2
  127. package/dist/sync/folders.d.ts.map +1 -1
  128. package/dist/sync/folders.js +55 -2
  129. package/dist/sync/folders.js.map +1 -1
  130. package/dist/sync/forget-command.d.ts +22 -0
  131. package/dist/sync/forget-command.d.ts.map +1 -0
  132. package/dist/sync/forget-command.js +95 -0
  133. package/dist/sync/forget-command.js.map +1 -0
  134. package/dist/sync/git-bridge.d.ts +6 -0
  135. package/dist/sync/git-bridge.d.ts.map +1 -1
  136. package/dist/sync/git-bridge.js +64 -1
  137. package/dist/sync/git-bridge.js.map +1 -1
  138. package/dist/sync/gone-watch.d.ts +18 -0
  139. package/dist/sync/gone-watch.d.ts.map +1 -0
  140. package/dist/sync/gone-watch.js +142 -0
  141. package/dist/sync/gone-watch.js.map +1 -0
  142. package/dist/sync/gone.d.ts +22 -0
  143. package/dist/sync/gone.d.ts.map +1 -0
  144. package/dist/sync/gone.js +60 -0
  145. package/dist/sync/gone.js.map +1 -0
  146. package/dist/sync/local-sandboxes.d.ts +6 -0
  147. package/dist/sync/local-sandboxes.d.ts.map +1 -0
  148. package/dist/sync/local-sandboxes.js +26 -0
  149. package/dist/sync/local-sandboxes.js.map +1 -0
  150. package/dist/sync/mirror.d.ts +7 -0
  151. package/dist/sync/mirror.d.ts.map +1 -1
  152. package/dist/sync/mirror.js +434 -111
  153. package/dist/sync/mirror.js.map +1 -1
  154. package/dist/sync/mutagen.d.ts +50 -18
  155. package/dist/sync/mutagen.d.ts.map +1 -1
  156. package/dist/sync/mutagen.js +294 -80
  157. package/dist/sync/mutagen.js.map +1 -1
  158. package/dist/sync/project/adopt.d.ts +18 -0
  159. package/dist/sync/project/adopt.d.ts.map +1 -0
  160. package/dist/sync/project/adopt.js +86 -0
  161. package/dist/sync/project/adopt.js.map +1 -0
  162. package/dist/sync/{project-commands.d.ts → project/project-commands.d.ts} +4 -1
  163. package/dist/sync/project/project-commands.d.ts.map +1 -0
  164. package/dist/sync/{project-commands.js → project/project-commands.js} +10 -10
  165. package/dist/sync/project/project-commands.js.map +1 -0
  166. package/dist/sync/project/project-delivery.d.ts +23 -0
  167. package/dist/sync/project/project-delivery.d.ts.map +1 -0
  168. package/dist/sync/project/project-delivery.js +315 -0
  169. package/dist/sync/project/project-delivery.js.map +1 -0
  170. package/dist/sync/{project-files.d.ts → project/project-files.d.ts} +1 -1
  171. package/dist/sync/project/project-files.d.ts.map +1 -0
  172. package/dist/sync/{project-files.js → project/project-files.js} +5 -2
  173. package/dist/sync/project/project-files.js.map +1 -0
  174. package/dist/sync/{project-local.d.ts → project/project-local.d.ts} +1 -1
  175. package/dist/sync/project/project-local.d.ts.map +1 -0
  176. package/dist/sync/{project-local.js → project/project-local.js} +1 -1
  177. package/dist/sync/project/project-local.js.map +1 -0
  178. package/dist/sync/{project-remote.d.ts → project/project-remote.d.ts} +1 -1
  179. package/dist/sync/project/project-remote.d.ts.map +1 -0
  180. package/dist/sync/{project-remote.js → project/project-remote.js} +1 -1
  181. package/dist/sync/project/project-remote.js.map +1 -0
  182. package/dist/sync/{project-transfer.d.ts → project/project-transfer.d.ts} +20 -6
  183. package/dist/sync/project/project-transfer.d.ts.map +1 -0
  184. package/dist/sync/{project-transfer.js → project/project-transfer.js} +26 -23
  185. package/dist/sync/project/project-transfer.js.map +1 -0
  186. package/dist/sync/report.d.ts.map +1 -1
  187. package/dist/sync/report.js +13 -7
  188. package/dist/sync/report.js.map +1 -1
  189. package/dist/sync/restore-points.d.ts +15 -11
  190. package/dist/sync/restore-points.d.ts.map +1 -1
  191. package/dist/sync/restore-points.js +25 -24
  192. package/dist/sync/restore-points.js.map +1 -1
  193. package/dist/sync/retire.d.ts +14 -0
  194. package/dist/sync/retire.d.ts.map +1 -0
  195. package/dist/sync/retire.js +66 -0
  196. package/dist/sync/retire.js.map +1 -0
  197. package/dist/sync/siblings.d.ts +27 -0
  198. package/dist/sync/siblings.d.ts.map +1 -0
  199. package/dist/sync/siblings.js +112 -0
  200. package/dist/sync/siblings.js.map +1 -0
  201. package/dist/sync/ssh.d.ts +1 -0
  202. package/dist/sync/ssh.d.ts.map +1 -1
  203. package/dist/sync/ssh.js +37 -23
  204. package/dist/sync/ssh.js.map +1 -1
  205. package/dist/sync/tunnel.d.ts +6 -2
  206. package/dist/sync/tunnel.d.ts.map +1 -1
  207. package/dist/sync/tunnel.js +45 -48
  208. package/dist/sync/tunnel.js.map +1 -1
  209. package/dist/upkeep/doctor.d.ts +5 -0
  210. package/dist/upkeep/doctor.d.ts.map +1 -0
  211. package/dist/upkeep/doctor.js +73 -0
  212. package/dist/upkeep/doctor.js.map +1 -0
  213. package/dist/upkeep/entry.d.ts +26 -0
  214. package/dist/upkeep/entry.d.ts.map +1 -0
  215. package/dist/upkeep/entry.js +2 -0
  216. package/dist/upkeep/entry.js.map +1 -0
  217. package/dist/upkeep/login.d.ts +15 -0
  218. package/dist/upkeep/login.d.ts.map +1 -0
  219. package/dist/upkeep/login.js +128 -0
  220. package/dist/upkeep/login.js.map +1 -0
  221. package/dist/upkeep/manifest.d.ts +3 -0
  222. package/dist/upkeep/manifest.d.ts.map +1 -0
  223. package/dist/upkeep/manifest.js +6 -0
  224. package/dist/upkeep/manifest.js.map +1 -0
  225. package/dist/upkeep/reconcile.d.ts +45 -0
  226. package/dist/upkeep/reconcile.d.ts.map +1 -0
  227. package/dist/upkeep/reconcile.js +159 -0
  228. package/dist/upkeep/reconcile.js.map +1 -0
  229. package/dist/upkeep/retention.d.ts +10 -0
  230. package/dist/upkeep/retention.d.ts.map +1 -0
  231. package/dist/upkeep/retention.js +71 -0
  232. package/dist/upkeep/retention.js.map +1 -0
  233. package/dist/upkeep/retired.d.ts +12 -0
  234. package/dist/upkeep/retired.d.ts.map +1 -0
  235. package/dist/upkeep/retired.js +125 -0
  236. package/dist/upkeep/retired.js.map +1 -0
  237. package/dist/upkeep/second-ic.d.ts +4 -0
  238. package/dist/upkeep/second-ic.d.ts.map +1 -0
  239. package/dist/upkeep/second-ic.js +36 -0
  240. package/dist/upkeep/second-ic.js.map +1 -0
  241. package/dist/upkeep/trash.d.ts +9 -0
  242. package/dist/upkeep/trash.d.ts.map +1 -0
  243. package/dist/upkeep/trash.js +77 -0
  244. package/dist/upkeep/trash.js.map +1 -0
  245. package/dist/watchdog.d.ts +13 -0
  246. package/dist/watchdog.d.ts.map +1 -0
  247. package/dist/watchdog.js +38 -0
  248. package/dist/watchdog.js.map +1 -0
  249. package/package.json +9 -9
  250. package/dist/autostart/legacy-autostart.d.ts +0 -4
  251. package/dist/autostart/legacy-autostart.d.ts.map +0 -1
  252. package/dist/autostart/legacy-autostart.js +0 -33
  253. package/dist/autostart/legacy-autostart.js.map +0 -1
  254. package/dist/device/tools/in-flight.d.ts.map +0 -1
  255. package/dist/device/tools/in-flight.js.map +0 -1
  256. package/dist/sync/project-commands.d.ts.map +0 -1
  257. package/dist/sync/project-commands.js.map +0 -1
  258. package/dist/sync/project-files.d.ts.map +0 -1
  259. package/dist/sync/project-files.js.map +0 -1
  260. package/dist/sync/project-local.d.ts.map +0 -1
  261. package/dist/sync/project-local.js.map +0 -1
  262. package/dist/sync/project-remote.d.ts.map +0 -1
  263. package/dist/sync/project-remote.js.map +0 -1
  264. package/dist/sync/project-transfer.d.ts.map +0 -1
  265. package/dist/sync/project-transfer.js.map +0 -1
  266. package/dist/wsl.d.ts.map +0 -1
  267. package/dist/wsl.js.map +0 -1
  268. /package/dist/device/{tools → sandbox-rounds}/in-flight.d.ts +0 -0
  269. /package/dist/device/{tools → sandbox-rounds}/in-flight.js +0 -0
  270. /package/dist/{wsl.d.ts → environments/wsl.d.ts} +0 -0
  271. /package/dist/{wsl.js → environments/wsl.js} +0 -0
@@ -1,17 +1,20 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import net from "node:net";
3
- import { setTimeout as sleep } from "node:timers/promises";
3
+ import { sleep } from "@intentic/base/async";
4
4
  import { errorMessage } from "@intentic/base/errors";
5
5
  import { plural } from "@intentic/base/format";
6
6
  import { writeFileAtomic } from "@intentic/base/fs";
7
7
  import { PortsListSchema } from "@intentic/sandbox-contract";
8
- import { mirrorHeartbeatPath, readState, removePairing, setFileSyncAutoPaused, setFileSyncSwapPaused, updateState, } from "./config.js";
8
+ import { isAttachedPairing, mirrorHeartbeatPath, pairingKey, pairingTransport, readState, removeSandboxPairings, setFileSyncAutoPaused, setFileSyncSwapPaused, setSandboxGone, setUnreachableSince, updateState, } from "./config.js";
9
9
  import { pairingEndpoint } from "./endpoint.js";
10
- import { createDaemonBases, dialedPairings } from "../daemon-base.js";
10
+ import { candidateBases, createDaemonBases, dialedPairings } from "../daemon-base.js";
11
+ import { answeredGone, goneStep, SandboxGoneError } from "./gone.js";
12
+ import { checkContainers, checkKeptHere, sandboxAnswered, sandboxSaidGone } from "./gone-watch.js";
13
+ import { retireSandbox } from "./retire.js";
11
14
  import { readSwapRecords } from "../device/sandbox-rounds/swap-records.js";
12
15
  import { publishedPortsReader } from "./docker-ports.js";
13
16
  import { realBridgeExec, runGitBridge } from "./git-bridge.js";
14
- import { ensureMutagen, ensureSyncSession, existingSyncSessions, forwardedPorts, forwardSessionName, healDerivedConflicts, mutagenForwardArgs, ourForwardSessions, pauseRunningSync, pauseUnreachableSync, retireOrphanSessions, resumeAutoPausedSync, resumeSwapPausedSync, runMutagenAsync, syncMode, syncSessionNames, } from "./mutagen.js";
17
+ import { ensureMutagen, ensureSyncSession, existingSyncSessions, forwardedPorts, forwardSessionName, healDerivedConflicts, MUTAGEN_CALL_TIMEOUT_MS, mutagenForwardArgs, ourForwardSessions, pauseRunningSync, pauseUnreachableSync, relabelBudget, retireOrphanSessions, resumeAutoPausedSync, resumeSwapPausedSync, runMutagenAsync, sayDaemonEventsTo, sessionLabels, syncMode, syncSessionNames, } from "./mutagen.js";
15
18
  import { quieted } from "./repeats.js";
16
19
  import { deviceReport, scopedReport } from "./report.js";
17
20
  import { pairingSshConfig, writeManagedSshConfig } from "./ssh.js";
@@ -37,6 +40,9 @@ export const fetchWorkspacePorts = async (base, syncToken) => {
37
40
  if (response.status === 401 || response.status === 403) {
38
41
  throw new SyncAuthError("the sandbox rejected the sync token: click 'Enable desktop sync' in your browser and re-run setup to mint a fresh one.");
39
42
  }
43
+ if (answeredGone(response)) {
44
+ throw new SandboxGoneError(`the platform says this sandbox no longer exists (${response.status}): ${await response.text()}`);
45
+ }
40
46
  if (!response.ok) {
41
47
  throw new Error(`reading the sandbox's ports failed (${response.status}): ${await response.text()}`);
42
48
  }
@@ -48,12 +54,17 @@ const localPortFree = (port) => new Promise((resolvePort) => {
48
54
  probe.listen(port, "127.0.0.1", () => probe.close(() => resolvePort(true)));
49
55
  });
50
56
  const mutagenExecutor = (mutagen, pairing, log) => ({
51
- terminate: (port) => void spawnSync(mutagen, ["forward", "terminate", forwardSessionName(pairing.sandboxId, port)], { stdio: "ignore", windowsHide: true }),
57
+ terminate: (port) => void spawnSync(mutagen, ["forward", "terminate", forwardSessionName(pairing.sandboxId, port)], {
58
+ stdio: "ignore",
59
+ windowsHide: true,
60
+ timeout: MUTAGEN_CALL_TIMEOUT_MS,
61
+ }),
52
62
  create: async (summary) => await runMutagenAsync(mutagen, mutagenForwardArgs({
53
63
  name: forwardSessionName(pairing.sandboxId, summary.port),
54
64
  port: summary.port,
55
65
  remote: pairingEndpoint(pairing),
56
66
  host: summary.host,
67
+ labels: sessionLabels(pairing.sandboxId, "forward"),
57
68
  }), log),
58
69
  isLocalPortFree: localPortFree,
59
70
  });
@@ -141,8 +152,13 @@ export const strandedForwards = (held, mirrored) => {
141
152
  return held.filter((port) => !kept.has(port));
142
153
  };
143
154
  const sweepStrandedForwards = (mutagen, sandboxId, mirrored, log) => {
144
- for (const port of strandedForwards(forwardedPorts(mutagen, sandboxId), mirrored)) {
145
- spawnSync(mutagen, ["forward", "terminate", forwardSessionName(sandboxId, port)], { stdio: "ignore", windowsHide: true });
155
+ const held = forwardedPorts(mutagen, sandboxId);
156
+ for (const port of held === undefined ? [] : strandedForwards(held, mirrored)) {
157
+ spawnSync(mutagen, ["forward", "terminate", forwardSessionName(sandboxId, port)], {
158
+ stdio: "ignore",
159
+ windowsHide: true,
160
+ timeout: MUTAGEN_CALL_TIMEOUT_MS,
161
+ });
146
162
  log(` localhost:${port}: stopped (this device was still holding a forward for it)`);
147
163
  }
148
164
  };
@@ -169,7 +185,13 @@ export const skippedPortsOf = (desired, mirrored, claimedBy, ignored) => {
169
185
  .map((summary) => {
170
186
  const heldBy = claimedBy.get(summary.port);
171
187
  const reason = ignored.has(summary.port) ? "ignored" : heldBy === undefined ? "busy" : "held-by-sandbox";
172
- return { port: summary.port, host: summary.host, reason, heldBy: reason === "held-by-sandbox" ? heldBy : undefined, command: summary.command };
188
+ return {
189
+ port: summary.port,
190
+ host: summary.host,
191
+ reason,
192
+ heldBy: reason === "held-by-sandbox" ? heldBy : undefined,
193
+ command: summary.command,
194
+ };
173
195
  });
174
196
  };
175
197
  export const recordedForwards = (pairings) => new Map(pairings.flatMap((held) => (held.mirroredPorts ?? []).map((port) => [port.port, held.sandboxId])));
@@ -185,8 +207,8 @@ export const handOver = (holding, sandboxId, mirrored) => {
185
207
  };
186
208
  export const othersHolding = (holding, sandboxId) => new Map([...holding].filter(([, holder]) => holder !== sandboxId));
187
209
  const beat = async () => await writeFileAtomic(mirrorHeartbeatPath, String(Date.now())).catch(() => { });
188
- const savePorts = async (sandboxId, mirroredPorts, skippedPorts) => await updateState((state) => ({
189
- pairings: state.pairings.map((held) => (held.sandboxId === sandboxId ? { ...held, mirroredPorts, skippedPorts } : held)),
210
+ const savePorts = async (key, mirroredPorts, skippedPorts) => await updateState((state) => ({
211
+ pairings: state.pairings.map((held) => (pairingKey(held) === key ? { ...held, mirroredPorts, skippedPorts } : held)),
190
212
  }));
191
213
  const retireSwitchedOff = async (mutagen, pairing, log) => {
192
214
  const mirrored = pairing.mirroredPorts ?? [];
@@ -206,6 +228,9 @@ const servePairing = async (context, pairing, base) => {
206
228
  const { mutagen, claimedBy, holding, holders, say: log } = context;
207
229
  const baseline = pairing.mirroredPorts ?? [];
208
230
  const ports = pairing.syncToken === undefined ? [] : await fetchWorkspacePorts(base, pairing.syncToken);
231
+ if (isAttachedPairing(pairing)) {
232
+ return [];
233
+ }
209
234
  if (pairing.mirrorOff === true) {
210
235
  await retireSwitchedOff(mutagen, pairing, log);
211
236
  return [];
@@ -215,28 +240,45 @@ const servePairing = async (context, pairing, base) => {
215
240
  const next = await reconcileForwards(mutagenExecutor(mutagen, pairing, log), baseline, ports, claimedBy, ignored, holds, log);
216
241
  const skipped = skippedPortsOf(ports, next, othersHolding(holding, pairing.sandboxId), ignored);
217
242
  if (!sameMirrorSet(baseline, next) || !sameSkippedSet(pairing.skippedPorts ?? [], skipped)) {
218
- await savePorts(pairing.sandboxId, next, skipped);
243
+ await savePorts(pairingKey(pairing), next, skipped);
219
244
  }
220
245
  return next;
221
246
  };
222
- const postReports = async (dialed, mutagen, unsupported, log) => {
223
- const reportable = dialed.filter(({ pairing }) => pairing.syncToken !== undefined && !unsupported.has(pairing.sandboxId));
247
+ export const reportCarriers = (dialed, unsupported) => {
248
+ const seen = new Set();
249
+ return dialed.filter(({ pairing }) => {
250
+ if (pairing.syncToken === undefined || pairing.goneSince !== undefined || unsupported.has(pairing.sandboxId) || seen.has(pairing.sandboxId)) {
251
+ return false;
252
+ }
253
+ seen.add(pairing.sandboxId);
254
+ return true;
255
+ });
256
+ };
257
+ const sendReport = async (base, pairing, report) => await fetch(`${base.replace(/\/$/, "")}/system/sync/report`, {
258
+ method: "POST",
259
+ headers: { "content-type": "application/json", "x-intentic-sync": pairing.syncToken ?? "" },
260
+ body: JSON.stringify(scopedReport(report, pairing.sandboxId)),
261
+ signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),
262
+ });
263
+ const postReports = async (dialed, mutagen, unsupported, gone, log) => {
264
+ const reportable = reportCarriers(dialed, unsupported);
224
265
  if (reportable.length === 0) {
225
266
  return;
226
267
  }
227
268
  const report = await deviceReport(mutagen);
228
269
  for (const { pairing, base } of reportable) {
229
270
  try {
230
- const response = await fetch(`${base.replace(/\/$/, "")}/system/sync/report`, {
231
- method: "POST",
232
- headers: { "content-type": "application/json", "x-intentic-sync": pairing.syncToken ?? "" },
233
- body: JSON.stringify(scopedReport(report, pairing.sandboxId)),
234
- signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),
235
- });
271
+ const response = await sendReport(base, pairing, report);
236
272
  if (response.status === 404) {
237
273
  unsupported.add(pairing.sandboxId);
238
274
  log(` ${pairing.sandboxId}: this sandbox is running a daemon without machine reports, its Devices view will stay empty.`);
239
275
  }
276
+ else if (answeredGone(response)) {
277
+ await gone(pairing);
278
+ }
279
+ else if (!response.ok) {
280
+ log(` ${pairing.sandboxId}: report not taken (${response.status})`);
281
+ }
240
282
  }
241
283
  catch (error) {
242
284
  log(` ${pairing.sandboxId}: report skipped: ${errorMessage(error)}`);
@@ -244,9 +286,10 @@ const postReports = async (dialed, mutagen, unsupported, log) => {
244
286
  }
245
287
  };
246
288
  const dropRevokedPairing = async (mutagen, sandboxId, log) => {
247
- await removePairing(sandboxId);
289
+ await removeSandboxPairings(sandboxId);
248
290
  retireOrphanSessions(mutagen, (await readState()).pairings, log);
249
291
  };
292
+ const keyOfSandbox = (key, sandboxId) => key === sandboxId || key.startsWith(`${sandboxId}~`);
250
293
  const guard = async (log, what, step) => {
251
294
  try {
252
295
  await step();
@@ -268,27 +311,39 @@ const absorbRejectedPoll = async (mutagen, pairing, rejectedPolls, repos, sessio
268
311
  log(`${pairing.sandboxId} rejected the sync token ${REVOKED_POLLS} polls in a row: this machine's enrollment was revoked.`);
269
312
  rejectedPolls.delete(pairing.sandboxId);
270
313
  repos.delete(pairing.sandboxId);
271
- sessionsPending.delete(pairing.sandboxId);
314
+ for (const key of [...sessionsPending].filter((held) => keyOfSandbox(held, pairing.sandboxId))) {
315
+ sessionsPending.delete(key);
316
+ }
272
317
  await dropRevokedPairing(mutagen, pairing.sandboxId, log);
273
318
  return true;
274
319
  };
275
320
  const absorbUnreachablePoll = async (mutagen, pairing, unreachable, log) => {
276
321
  const now = Date.now();
277
- const since = unreachable.get(pairing.sandboxId)?.since ?? now;
322
+ const since = unreachable.get(pairing.sandboxId)?.since ?? pairing.unreachableSince ?? now;
278
323
  unreachable.set(pairing.sandboxId, { since, lastTried: now });
324
+ if (pairing.unreachableSince === undefined) {
325
+ await setUnreachableSince(pairing.sandboxId, since);
326
+ }
279
327
  const mirrored = (pairing.mirroredPorts ?? []).length;
280
328
  if (shouldReleaseForwards(now - since, mirrored)) {
281
329
  await retirePairingMirror(mutagen, pairing.sandboxId);
282
330
  log(` ${pairing.sandboxId}: unreachable for ${RELEASE_FORWARDS_MS / 60_000} minutes; took its ${plural(mirrored, "port")} off localhost. They come back when it answers again.`);
283
331
  }
284
- if (pairing.fileSyncAutoPaused === true || !shouldAutoPauseFileSync(now - since) || !pauseUnreachableSync(mutagen, pairing)) {
332
+ return await pauseIfLongUnreachable(mutagen, pairing, now - since, log);
333
+ };
334
+ const pauseIfLongUnreachable = async (mutagen, pairing, unreachableForMs, log) => {
335
+ if (pairing.fileSyncAutoPaused === true || !shouldAutoPauseFileSync(unreachableForMs) || !pauseUnreachableSync(mutagen, pairing)) {
285
336
  return false;
286
337
  }
287
- await setFileSyncAutoPaused(pairing.sandboxId, true);
288
- log(` ${pairing.sandboxId}: unreachable for one hour; paused its Mutagen sessions to stop permanent reconnect/scanning load. They resume automatically when it returns.`);
338
+ await setFileSyncAutoPaused(pairingKey(pairing), true);
339
+ log(` ${pairingKey(pairing)}: unreachable for one hour; paused its Mutagen sessions to stop permanent reconnect/scanning load. They resume automatically when it returns.`);
289
340
  return true;
290
341
  };
291
- const absorbPairingFailure = async (error, mutagen, pairing, tracking, log) => {
342
+ const absorbPairingFailure = async (error, mutagen, pairing, tracking, fate, log) => {
343
+ if (error instanceof SandboxGoneError) {
344
+ await sandboxSaidGone(fate, pairing.sandboxId, "edge");
345
+ return { drop: true, paused: true };
346
+ }
292
347
  if (error instanceof SyncAuthError) {
293
348
  const drop = await absorbRejectedPoll(mutagen, pairing, tracking.rejectedPolls, tracking.repos, tracking.sessionsPending, log);
294
349
  return { drop, paused: false };
@@ -299,42 +354,97 @@ const healPairing = async (mutagen, pairing, tick, log) => {
299
354
  if (tick % HEAL_EVERY_TICKS !== 0) {
300
355
  return;
301
356
  }
302
- await guard(log, `${pairing.sandboxId}: clearing derived residue`, async () => void (await healDerivedConflicts(mutagen, pairing, log)));
357
+ await guard(log, `${pairingKey(pairing)}: clearing derived residue`, async () => void (await healDerivedConflicts(mutagen, pairing, log)));
303
358
  };
304
- const setupOf = (pairing) => [pairing.sandboxId, pairing.localDir ?? "", pairing.syncToken ?? "", syncMode(pairing)].join("\n");
359
+ const setupOf = (pairing) => [pairingKey(pairing), pairing.localDir ?? "", pairing.syncToken ?? "", syncMode(pairing), pairingTransport(pairing)].join("\n");
305
360
  export const unpreparedSetups = (pairings, prepared) => pairings.filter((held) => !prepared.has(setupOf(held)));
306
361
  export const markPrepared = (prepared, pairing) => void prepared.add(setupOf(pairing));
307
- const prepareSession = async (mutagen, pairing, pending, say) => {
362
+ const prepareSession = async (mutagen, pairing, pending, relabels, say) => {
308
363
  let converged = false;
309
- await guard(say, `${pairing.sandboxId}: preparing its file sync`, async () => {
310
- converged = await ensureSyncSession(mutagen, pairing, say);
364
+ await guard(say, `${pairingKey(pairing)}: preparing its file sync`, async () => {
365
+ converged = await ensureSyncSession(mutagen, pairing, say, relabels);
311
366
  });
312
367
  if (converged) {
313
- pending.delete(pairing.sandboxId);
368
+ pending.delete(pairingKey(pairing));
314
369
  }
315
370
  else {
316
- pending.add(pairing.sandboxId);
371
+ pending.add(pairingKey(pairing));
317
372
  }
318
373
  return converged;
319
374
  };
320
- const dormant = (mutagen, pairing) => pairing.fileSyncAutoPaused === true && existingSyncSessions(mutagen, syncSessionNames(pairing)).length > 0;
321
- const prepareSessions = async (mutagen, pairings, prepared, pending, say) => {
322
- for (const pairing of unpreparedSetups(pairings, prepared)) {
375
+ const dormant = (mutagen, pairing) => pairing.goneSince !== undefined ||
376
+ pairing.fileSyncPausedFor !== undefined ||
377
+ (pairing.fileSyncAutoPaused === true && (existingSyncSessions(mutagen, syncSessionNames(pairing)) ?? [undefined]).length > 0);
378
+ export const readyToPrepare = async (setups, firstCopyAhead, announce) => {
379
+ const told = new Map();
380
+ const ready = [];
381
+ for (const pairing of setups) {
382
+ if (isAttachedPairing(pairing) && pairing.mode === "sync" && pairing.localDir !== undefined && firstCopyAhead(pairing)) {
383
+ const said = told.get(pairing.sandboxId) ?? (await announce(pairing));
384
+ told.set(pairing.sandboxId, said);
385
+ if (!said) {
386
+ continue;
387
+ }
388
+ }
389
+ ready.push(pairing);
390
+ }
391
+ return ready;
392
+ };
393
+ const announcer = (mutagen, bases, unsupported, unreachable, gone, say) => async (pairing) => {
394
+ if (pairing.goneSince !== undefined) {
395
+ return false;
396
+ }
397
+ if (pairing.syncToken === undefined || unsupported.has(pairing.sandboxId)) {
398
+ return true;
399
+ }
400
+ if (!pollDue(unreachable.get(pairing.sandboxId), Date.now())) {
401
+ return false;
402
+ }
403
+ try {
404
+ const response = await sendReport(await bases.resolve(pairing), pairing, await deviceReport(mutagen));
405
+ if (response.status === 404) {
406
+ unsupported.add(pairing.sandboxId);
407
+ say(` ${pairing.sandboxId}: this sandbox is running a daemon without machine reports, its Devices view will stay empty.`);
408
+ return true;
409
+ }
410
+ if (answeredGone(response)) {
411
+ await gone(pairing);
412
+ return false;
413
+ }
414
+ if (!response.ok) {
415
+ say(` ${pairingKey(pairing)}: ${pairing.sandboxId} did not take the report naming this folder (${response.status}); its first copy waits for it.`);
416
+ }
417
+ return response.ok;
418
+ }
419
+ catch (error) {
420
+ say(` ${pairingKey(pairing)}: ${pairing.sandboxId} could not be told of this folder (${errorMessage(error)}); its first copy waits until it can.`);
421
+ return false;
422
+ }
423
+ };
424
+ const prepareSessions = async (mutagen, pairings, prepared, pending, announce, passes, say) => {
425
+ const firstCopyAhead = (pairing) => (existingSyncSessions(mutagen, syncSessionNames(pairing)) ?? []).length === 0;
426
+ const unprepared = unpreparedSetups(pairings, prepared);
427
+ const docker = unprepared.filter((pairing) => pairingTransport(pairing) === "docker");
428
+ if (docker.length > 0) {
429
+ await guard(say, "asking after the containers docker pairings reach", async () => await checkContainers(passes.fate, docker, answeringOf(passes.tracking)));
430
+ }
431
+ for (const pairing of await readyToPrepare(unprepared, firstCopyAhead, announce)) {
323
432
  markPrepared(prepared, pairing);
324
433
  if (dormant(mutagen, pairing)) {
325
- pending.add(pairing.sandboxId);
434
+ pending.add(pairingKey(pairing));
326
435
  continue;
327
436
  }
328
- await prepareSession(mutagen, pairing, pending, say);
437
+ await prepareSession(mutagen, pairing, pending, passes.relabels, say);
329
438
  }
330
439
  };
331
- const retryPendingSessions = async (mutagen, pairings, sessionsPending, tick, say) => {
440
+ const answeringOf = (tracking) => (sandboxId) => !tracking.unreachable.has(sandboxId);
441
+ const retryPendingSessions = async (mutagen, pairings, sessionsPending, tick, relabels, say) => {
332
442
  if (sessionsPending.size === 0 || tick % SESSION_RETRY_EVERY_TICKS !== 0) {
333
443
  return;
334
444
  }
335
- for (const pairing of pairings.filter((held) => sessionsPending.has(held.sandboxId) && !dormant(mutagen, held))) {
336
- if (await prepareSession(mutagen, pairing, sessionsPending, say)) {
337
- say(` ${pairing.sandboxId}: file sync is running on this build's rules`);
445
+ for (const pairing of pairings.filter((held) => sessionsPending.has(pairingKey(held)) && !dormant(mutagen, held))) {
446
+ if (await prepareSession(mutagen, pairing, sessionsPending, relabels, say)) {
447
+ say(` ${pairingKey(pairing)}: file sync is running on this build's rules`);
338
448
  }
339
449
  }
340
450
  };
@@ -350,19 +460,19 @@ const holdSyncDuringSwaps = async (mutagen, pairings, unreachable, say) => {
350
460
  }
351
461
  const step = swapPauseStep(pairing, holds);
352
462
  if (step === "pause" && pauseRunningSync(mutagen, pairing)) {
353
- await setFileSyncSwapPaused(pairing.sandboxId, true);
354
- say(` ${pairing.sandboxId}: its sandbox is being swapped on this machine; file sync is paused until the new version has held.`);
463
+ await setFileSyncSwapPaused(pairingKey(pairing), true);
464
+ say(` ${pairingKey(pairing)}: its sandbox is being swapped on this machine; file sync is paused until the new version has held.`);
355
465
  }
356
466
  else if (step === "resume") {
357
467
  resumeSwapPausedSync(mutagen, pairing);
358
- await setFileSyncSwapPaused(pairing.sandboxId, false);
359
- say(` ${pairing.sandboxId}: its sandbox's swap is settled; file sync resumed.`);
468
+ await setFileSyncSwapPaused(pairingKey(pairing), false);
469
+ say(` ${pairingKey(pairing)}: its sandbox's swap is settled; file sync resumed.`);
360
470
  }
361
471
  }
362
472
  return held;
363
473
  };
364
474
  const runPairingPass = async (context, pairing, base) => {
365
- const { mutagen, tick, claimedBy, holding, tracking, bases, swapHeld, say } = context;
475
+ const { mutagen, tick, claimedBy, holding, tracking, bases, swapHeld, fate, say } = context;
366
476
  const { rejectedPolls, unreachable, repos } = tracking;
367
477
  const swapping = swapHeld.has(pairing.sandboxId);
368
478
  let pausedThisPass = false;
@@ -370,28 +480,36 @@ const runPairingPass = async (context, pairing, base) => {
370
480
  const mirrored = await servePairing(context, pairing, base);
371
481
  rejectedPolls.delete(pairing.sandboxId);
372
482
  unreachable.delete(pairing.sandboxId);
373
- if (!swapping && resumeAutoPausedSync(mutagen, pairing)) {
374
- await setFileSyncAutoPaused(pairing.sandboxId, false);
375
- say(` ${pairing.sandboxId}: reachable again; resumed its automatically paused file sync`);
483
+ if (context.pairings.some((held) => held.sandboxId === pairing.sandboxId && held.goneSince !== undefined)) {
484
+ await sandboxAnswered(fate, pairing.sandboxId);
376
485
  }
377
- for (const port of mirrored) {
378
- claimedBy.set(port.port, pairing.sandboxId);
486
+ if (context.pairings.some((held) => held.sandboxId === pairing.sandboxId && held.unreachableSince !== undefined)) {
487
+ await setUnreachableSince(pairing.sandboxId, undefined);
379
488
  }
380
- handOver(holding, pairing.sandboxId, mirrored);
381
- if (tick % STRANDED_SWEEP_EVERY_TICKS === 0) {
382
- sweepStrandedForwards(mutagen, pairing.sandboxId, mirrored, say);
489
+ if (!swapping && resumeAutoPausedSync(mutagen, pairing)) {
490
+ await setFileSyncAutoPaused(pairingKey(pairing), false);
491
+ say(` ${pairingKey(pairing)}: reachable again; resumed its automatically paused file sync`);
492
+ }
493
+ if (!isAttachedPairing(pairing)) {
494
+ for (const port of mirrored) {
495
+ claimedBy.set(port.port, pairing.sandboxId);
496
+ }
497
+ handOver(holding, pairing.sandboxId, mirrored);
498
+ if (tick % STRANDED_SWEEP_EVERY_TICKS === 0) {
499
+ sweepStrandedForwards(mutagen, pairing.sandboxId, mirrored, say);
500
+ }
383
501
  }
384
502
  }
385
503
  catch (error) {
386
504
  bases.failed(pairing.sandboxId);
387
- const outcome = await absorbPairingFailure(error, mutagen, pairing, tracking, say);
505
+ const outcome = await absorbPairingFailure(error, mutagen, pairing, tracking, fate, say);
388
506
  if (outcome.drop) {
389
507
  return;
390
508
  }
391
509
  pausedThisPass = outcome.paused;
392
- say(` ${pairing.sandboxId}: reconcile skipped: ${errorMessage(error)}`);
510
+ say(` ${pairingKey(pairing)}: reconcile skipped: ${errorMessage(error)}`);
393
511
  }
394
- if (pairing.fileSyncAutoPaused === true || pausedThisPass || swapping) {
512
+ if (pairing.fileSyncAutoPaused === true || pairing.fileSyncPausedFor !== undefined || pausedThisPass || swapping) {
395
513
  return;
396
514
  }
397
515
  await healPairing(mutagen, pairing, tick, say);
@@ -409,83 +527,288 @@ const runPairingPass = async (context, pairing, base) => {
409
527
  say(` ${pairing.sandboxId}: git bridge skipped: ${errorMessage(error)}`);
410
528
  }
411
529
  };
412
- export const runMirrorWatch = async (log) => {
413
- const mutagen = await ensureMutagen();
414
- const say = quieted(log);
415
- const initial = await readState();
416
- if (initial.pairings.length === 0) {
417
- log("no sandboxes are paired: nothing to mirror. Enable it from a sandbox's Desktop sync card.");
530
+ export const pollingPairings = (pairings) => {
531
+ const polling = new Set();
532
+ for (const sandboxId of new Set(pairings.map((pairing) => pairing.sandboxId))) {
533
+ const own = pairings.filter((pairing) => pairing.sandboxId === sandboxId);
534
+ const poller = own.find((pairing) => !isAttachedPairing(pairing)) ?? own[0];
535
+ if (poller !== undefined) {
536
+ polling.add(pairingKey(poller));
537
+ }
538
+ }
539
+ return polling;
540
+ };
541
+ const followSandbox = async (context, pairing) => {
542
+ const { mutagen, tick, tracking, swapHeld, say } = context;
543
+ if (pairing.goneSince !== undefined || pairing.fileSyncPausedFor !== undefined) {
418
544
  return;
419
545
  }
420
- await guard(say, "refreshing the ssh configuration", async () => await writeManagedSshConfig(pairingSshConfig(initial.pairings)));
421
- const tunnels = createTunnelPool(say);
422
- const bases = createDaemonBases(say);
423
- await guard(say, "opening the sync transports", async () => await tunnels.reconcile(tunnelTargets(await dialedPairings(initial.pairings, bases))));
424
- const sessionsPending = new Set();
425
- const sessionsPrepared = new Set();
426
- await prepareSessions(mutagen, initial.pairings, sessionsPrepared, sessionsPending, say);
427
- await guard(say, "retiring orphaned sessions", () => retireOrphanSessions(mutagen, initial.pairings, say));
428
- log(`sync started; polling ${plural(initial.pairings.length, "paired sandbox")} every ${POLL_MS / 1000}s`);
429
- const rejectedPolls = new Map();
430
- const unreachable = new Map();
431
- const repos = new Map();
432
- const tracking = { rejectedPolls, unreachable, repos, sessionsPending };
433
- const holders = { dockerPorts: publishedPortsReader(say), freeSince: new Map() };
434
- const reportUnsupported = new Set();
435
- for (let tick = 0;; tick += 1) {
436
- const state = await readState().catch((error) => {
437
- say(` tick skipped: the sync state didn't read (${errorMessage(error)})`);
438
- return undefined;
546
+ const down = tracking.unreachable.get(pairing.sandboxId)?.since ?? pairing.unreachableSince;
547
+ const swapping = swapHeld.has(pairing.sandboxId);
548
+ if (down === undefined) {
549
+ if (!swapping && resumeAutoPausedSync(mutagen, pairing)) {
550
+ await setFileSyncAutoPaused(pairingKey(pairing), false);
551
+ say(` ${pairingKey(pairing)}: reachable again; resumed its automatically paused file sync`);
552
+ }
553
+ }
554
+ else if (await pauseIfLongUnreachable(mutagen, pairing, Date.now() - down, say)) {
555
+ return;
556
+ }
557
+ if (pairing.fileSyncAutoPaused === true || swapping) {
558
+ return;
559
+ }
560
+ await healPairing(mutagen, pairing, tick, say);
561
+ };
562
+ const passGone = async (context, pairing) => {
563
+ const now = Date.now();
564
+ const step = goneStep(pairing, now);
565
+ if (step === undefined) {
566
+ return "pass";
567
+ }
568
+ if (step === "recheck") {
569
+ await setSandboxGone(pairing.sandboxId, { since: pairing.goneSince ?? now, checkedAt: now, by: pairing.goneBy ?? "edge" });
570
+ return "ask";
571
+ }
572
+ if (step === "retire") {
573
+ const { mutagen, tracking, say } = context;
574
+ await guard(say, `${pairing.sandboxId}: retiring its pairing`, async () => {
575
+ await retireSandbox(pairing.sandboxId, say, `gone since ${new Date(pairing.goneSince ?? Date.now()).toISOString().slice(0, 10)}`, {
576
+ mutagen,
577
+ });
439
578
  });
440
- if (state === undefined) {
441
- await sleep(POLL_MS);
579
+ tracking.unreachable.delete(pairing.sandboxId);
580
+ tracking.rejectedPolls.delete(pairing.sandboxId);
581
+ tracking.repos.delete(pairing.sandboxId);
582
+ for (const key of [...tracking.sessionsPending].filter((held) => keyOfSandbox(held, pairing.sandboxId))) {
583
+ tracking.sessionsPending.delete(key);
584
+ }
585
+ }
586
+ return "skip";
587
+ };
588
+ const runPasses = async (context, dialed, pairings, live) => {
589
+ const polling = pollingPairings(pairings);
590
+ for (const { pairing, base } of dialed.filter((held) => polling.has(pairingKey(held.pairing)))) {
591
+ if (!live()) {
592
+ return;
593
+ }
594
+ const gone = await passGone(context, pairing);
595
+ if (gone === "skip") {
596
+ continue;
597
+ }
598
+ if (gone === "pass" && !pollDue(context.tracking.unreachable.get(pairing.sandboxId), Date.now())) {
442
599
  continue;
443
600
  }
444
- if (state.pairings.length === 0) {
445
- await tunnels.stopAll();
446
- log("no sandboxes are paired any more: sync stopping. Re-enable from a sandbox's Desktop sync card.");
601
+ await runPairingPass(context, pairing, base);
602
+ context.progress(`${pairingKey(pairing)}'s pass`);
603
+ }
604
+ for (const { pairing } of dialed.filter((held) => !polling.has(pairingKey(held.pairing)))) {
605
+ if (!live()) {
447
606
  return;
448
607
  }
449
- const dialed = await dialedPairings(state.pairings, bases);
450
- await guard(say, "reconciling the sync transports", async () => await tunnels.reconcile(tunnelTargets(dialed)));
451
- await prepareSessions(mutagen, state.pairings, sessionsPrepared, sessionsPending, say);
452
- await retryPendingSessions(mutagen, state.pairings, sessionsPending, tick, say);
453
- let swapHeld = new Set();
454
- await guard(say, "holding file sync still during swaps", async () => {
455
- swapHeld = await holdSyncDuringSwaps(mutagen, state.pairings, unreachable, say);
456
- });
457
- const claimedBy = new Map();
458
- const holding = recordedForwards(state.pairings);
459
- for (const { pairing, base } of dialed) {
460
- if (!pollDue(unreachable.get(pairing.sandboxId), Date.now())) {
461
- continue;
608
+ await guard(context.say, `${pairingKey(pairing)}: following its sandbox`, async () => await followSandbox(context, pairing));
609
+ }
610
+ };
611
+ const ORPHAN_SWEEP_EVERY_TICKS = 12;
612
+ const CONTAINER_CHECK_EVERY_TICKS = 12;
613
+ const KEPT_HERE_CHECK_EVERY_TICKS = 120;
614
+ const reachedLocally = (dialed) => new Set(dialed.flatMap(({ pairing, base }) => {
615
+ const candidates = candidateBases(pairing.sandboxUrl);
616
+ return candidates.length > 1 && candidates[0] === base ? [pairing.sandboxId] : [];
617
+ }));
618
+ const standingSweeps = async (context, dialed) => {
619
+ const { mutagen, tick, tracking, fate, say } = context;
620
+ if (tick > 0 && tick % ORPHAN_SWEEP_EVERY_TICKS === 0) {
621
+ await guard(say, "retiring orphaned sessions", async () => retireOrphanSessions(mutagen, (await readState()).pairings, say));
622
+ }
623
+ if (tick % CONTAINER_CHECK_EVERY_TICKS === 0) {
624
+ await guard(say, "asking after the containers docker pairings reach", async () => await checkContainers(fate, (await readState()).pairings, answeringOf(tracking)));
625
+ }
626
+ if (tick % KEPT_HERE_CHECK_EVERY_TICKS === 0) {
627
+ await guard(say, "asking this machine's ic which sandboxes it keeps", async () => await checkKeptHere(fate, (await readState()).pairings, reachedLocally(dialed), undefined, answeringOf(tracking)));
628
+ }
629
+ };
630
+ export const WATCHER_STALL_MS = 20 * 60_000;
631
+ const STALL_CHECK_MS = 30_000;
632
+ export const watcherStalled = (progress, now) => now - progress >= WATCHER_STALL_MS;
633
+ export const runMirrorWatch = async (log) => {
634
+ for (;;) {
635
+ if ((await watchGeneration(log)) === "done") {
636
+ return;
637
+ }
638
+ }
639
+ };
640
+ const watchGeneration = async (log) => {
641
+ const abandon = new AbortController();
642
+ const generation = { signal: abandon.signal, progress: Date.now(), step: "starting", stopTransports: undefined };
643
+ const loop = watchLoop(log, generation);
644
+ let timer;
645
+ const stall = new Promise((resolve) => {
646
+ timer = setInterval(() => {
647
+ if (watcherStalled(generation.progress, Date.now())) {
648
+ resolve("stalled");
462
649
  }
463
- await runPairingPass({ mutagen, tick, claimedBy, holding, tracking, bases, swapHeld, holders, say }, pairing, base);
650
+ }, STALL_CHECK_MS);
651
+ });
652
+ try {
653
+ const outcome = await Promise.race([loop.then(() => "done"), stall]);
654
+ if (outcome === "stalled") {
655
+ abandon.abort();
656
+ loop.catch(() => undefined);
657
+ log(`sync watcher: no progress for ${WATCHER_STALL_MS / 60_000} minutes (it was at: ${generation.step}); restarting it. Its heartbeat had gone stale, which \`intentic-machine status\` reports.`);
658
+ await generation
659
+ .stopTransports?.()
660
+ .catch((error) => log(`sync watcher: could not stop the stalled watcher's tunnels (${errorMessage(error)})`));
464
661
  }
465
- if (tick % REPORT_EVERY_TICKS === 0) {
466
- await guard(say, "posting this machine's reports", async () => await postReports(dialed, mutagen, reportUnsupported, say));
662
+ return outcome;
663
+ }
664
+ finally {
665
+ clearInterval(timer);
666
+ }
667
+ };
668
+ const watchLoop = async (log, generation) => {
669
+ const watch = await startWatch(log, generation);
670
+ if (watch === undefined) {
671
+ return;
672
+ }
673
+ for (let tick = 0; watch.live(); tick += 1) {
674
+ if ((await watchTick(watch, tick)) === "stop") {
675
+ return;
467
676
  }
468
- await beat();
469
- await sleep(POLL_MS);
677
+ await sleep(POLL_MS, { signal: generation.signal });
678
+ }
679
+ };
680
+ const startWatch = async (log, generation) => {
681
+ const mutagen = await ensureMutagen();
682
+ const say = quieted(log);
683
+ sayDaemonEventsTo(say);
684
+ const initial = await readState();
685
+ if (initial.pairings.length === 0) {
686
+ log("no sandboxes are paired: nothing to mirror. Enable it from a sandbox's Desktop sync card.");
687
+ return undefined;
470
688
  }
689
+ const sshConfig = pairingSshConfig(initial.pairings);
690
+ await guard(say, "refreshing the ssh configuration", async () => await writeManagedSshConfig(sshConfig));
691
+ const tunnels = createTunnelPool(say, generation.signal);
692
+ generation.stopTransports = tunnels.stopAll;
693
+ const bases = createDaemonBases(say);
694
+ await guard(say, "opening the sync transports", async () => await tunnels.reconcile(tunnelTargets(await dialedPairings(initial.pairings, bases))));
695
+ const tracking = { rejectedPolls: new Map(), unreachable: new Map(), repos: new Map(), sessionsPending: new Set() };
696
+ const reportUnsupported = new Set();
697
+ const fate = { mutagen, releaseForwards: async (sandboxId) => await retirePairingMirror(mutagen, sandboxId), say };
698
+ const gone = async (pairing) => await sandboxSaidGone(fate, pairing.sandboxId, "edge");
699
+ const watch = {
700
+ log,
701
+ say,
702
+ mutagen,
703
+ live: () => !generation.signal.aborted,
704
+ progress: (step) => {
705
+ generation.signal.throwIfAborted();
706
+ generation.progress = Date.now();
707
+ generation.step = step;
708
+ },
709
+ tunnels,
710
+ bases,
711
+ tracking,
712
+ sessionsPrepared: new Set(),
713
+ reportUnsupported,
714
+ fate,
715
+ gone,
716
+ announce: announcer(mutagen, bases, reportUnsupported, tracking.unreachable, gone, say),
717
+ holders: { dockerPorts: publishedPortsReader(say), freeSince: new Map() },
718
+ sshConfig,
719
+ };
720
+ await prepareSessions(mutagen, initial.pairings, watch.sessionsPrepared, tracking.sessionsPending, watch.announce, { fate, relabels: relabelBudget(1), tracking }, say);
721
+ await guard(say, "retiring orphaned sessions", () => retireOrphanSessions(mutagen, initial.pairings, say));
722
+ log(`sync started; polling ${plural(initial.pairings.length, "paired sandbox")} every ${POLL_MS / 1000}s`);
723
+ return watch;
724
+ };
725
+ const watchTick = async (watch, tick) => {
726
+ const { mutagen, say, tracking, fate, progress } = watch;
727
+ progress("the start of a pass");
728
+ const state = await readState().catch((error) => {
729
+ say(` tick skipped: the sync state didn't read (${errorMessage(error)})`);
730
+ return undefined;
731
+ });
732
+ if (state === undefined) {
733
+ return "next";
734
+ }
735
+ if (state.pairings.length === 0) {
736
+ await watch.tunnels.stopAll();
737
+ watch.log("no sandboxes are paired any more: sync stopping. Re-enable from a sandbox's Desktop sync card.");
738
+ return "stop";
739
+ }
740
+ const wantedConfig = pairingSshConfig(state.pairings);
741
+ if (wantedConfig !== watch.sshConfig) {
742
+ await guard(say, "refreshing the ssh configuration", async () => await writeManagedSshConfig(wantedConfig));
743
+ watch.sshConfig = wantedConfig;
744
+ }
745
+ const dialed = await dialedPairings(state.pairings, watch.bases);
746
+ await guard(say, "reconciling the sync transports", async () => await watch.tunnels.reconcile(tunnelTargets(dialed)));
747
+ progress("the sync transports");
748
+ const relabels = relabelBudget(1);
749
+ await prepareSessions(mutagen, state.pairings, watch.sessionsPrepared, tracking.sessionsPending, watch.announce, { fate, relabels, tracking }, say);
750
+ await retryPendingSessions(mutagen, state.pairings, tracking.sessionsPending, tick, relabels, say);
751
+ progress("preparing file sync");
752
+ if (!watch.live()) {
753
+ return "stop";
754
+ }
755
+ let swapHeld = new Set();
756
+ await guard(say, "holding file sync still during swaps", async () => {
757
+ swapHeld = await holdSyncDuringSwaps(mutagen, state.pairings, tracking.unreachable, say);
758
+ });
759
+ const context = {
760
+ mutagen,
761
+ tick,
762
+ claimedBy: new Map(),
763
+ holding: recordedForwards(state.pairings),
764
+ tracking,
765
+ bases: watch.bases,
766
+ swapHeld,
767
+ holders: watch.holders,
768
+ fate,
769
+ relabels,
770
+ progress,
771
+ pairings: state.pairings,
772
+ say,
773
+ };
774
+ await runPasses(context, dialed, state.pairings, watch.live);
775
+ if (!watch.live()) {
776
+ return "stop";
777
+ }
778
+ if (tick % REPORT_EVERY_TICKS === 0) {
779
+ await guard(say, "posting this machine's reports", async () => await postReports(dialed, mutagen, watch.reportUnsupported, watch.gone, say));
780
+ }
781
+ await standingSweeps(context, dialed);
782
+ progress("the end of a pass");
783
+ await beat();
784
+ return "next";
471
785
  };
472
786
  const teardownForwards = async (mutagen, sandboxId) => {
473
- const names = ourForwardSessions(mutagen, sandboxId);
787
+ const recorded = async () => (await readState()).pairings
788
+ .filter((held) => sandboxId === undefined || held.sandboxId === sandboxId)
789
+ .flatMap((held) => (held.mirroredPorts ?? []).map((port) => forwardSessionName(held.sandboxId, port.port)));
790
+ const names = ourForwardSessions(mutagen, sandboxId) ?? (await recorded());
474
791
  if (names.length > 0) {
475
- spawnSync(mutagen, ["forward", "terminate", ...names], { stdio: "ignore", windowsHide: true });
792
+ spawnSync(mutagen, ["forward", "terminate", ...names], { stdio: "ignore", windowsHide: true, timeout: MUTAGEN_CALL_TIMEOUT_MS });
476
793
  }
477
794
  await updateState((state) => ({
478
- pairings: state.pairings.map((held) => sandboxId === undefined || held.sandboxId === sandboxId ? { ...held, mirroredPorts: [], skippedPorts: [] } : held),
795
+ pairings: state.pairings.map((held) => (sandboxId === undefined || held.sandboxId === sandboxId) && !isAttachedPairing(held)
796
+ ? { ...held, mirroredPorts: [], skippedPorts: [] }
797
+ : held),
479
798
  }));
480
799
  return names.length;
481
800
  };
482
801
  export const retirePairingMirror = async (mutagen, sandboxId) => await teardownForwards(mutagen, sandboxId);
483
802
  export const retireMirroredPort = async (mutagen, sandboxId, port) => {
484
- spawnSync(mutagen, ["forward", "terminate", forwardSessionName(sandboxId, port)], { stdio: "ignore", windowsHide: true });
803
+ spawnSync(mutagen, ["forward", "terminate", forwardSessionName(sandboxId, port)], {
804
+ stdio: "ignore",
805
+ windowsHide: true,
806
+ timeout: MUTAGEN_CALL_TIMEOUT_MS,
807
+ });
485
808
  let wasMirrored = false;
486
809
  await updateState((state) => ({
487
810
  pairings: state.pairings.map((held) => {
488
- if (held.sandboxId !== sandboxId) {
811
+ if (held.sandboxId !== sandboxId || isAttachedPairing(held)) {
489
812
  return held;
490
813
  }
491
814
  const mirrored = held.mirroredPorts ?? [];