heyquilt 0.3.90 → 0.3.107

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 (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +135 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +142 -6
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +146 -19
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +121 -11
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +140 -28
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. package/src/ui/merges.js +0 -158
@@ -1,9 +1,12 @@
1
1
  // The relay on many machines, from the accounts API's side (docs/superpowers/specs/2026-10-17-relay-scaling.md).
2
2
  // Each room (session) lives on one relay machine. Machines say hello when they start and report
3
- // every minute (src/relay-cluster.js); a room nobody placed yet goes to the least loaded machine;
4
- // a heavy room gets a machine of its own, which the API makes through Fly's Machines API
5
- // (relay-machines.js) and then moves the room to. The API reaches one machine through the relay's
6
- // public address with `fly-force-instance-id`, so every call lands where it is meant to.
3
+ // every minute (src/relay-cluster.js) with their own load (memory, event-loop lag, CPU, traffic)
4
+ // and each room's. A room nobody placed yet goes to the least loaded machine under its load target;
5
+ // a heavy room gets a machine of its own. The cluster scales both ways on that load: a machine
6
+ // over its target for a while hands its lightest rooms to one under it (or a new one), machines
7
+ // that could share one consolidate, and a shared machine left empty is stopped and destroyed with
8
+ // its volume (relay-machines.js, Fly's Machines API), always keeping a minimum. The API reaches one
9
+ // machine through the relay's public address with `fly-force-instance-id`.
7
10
  import { busyMinute, heavyReasons, HEAVY } from '../relay-metrics.js'
8
11
 
9
12
  // A machine that has not reported for this long takes no new rooms.
@@ -14,39 +17,150 @@ export const JOB_STALE_MS = 15 * 60 * 1000
14
17
  export const JOB_RETRY_MS = 10 * 60 * 1000
15
18
  // How long a new machine has to say hello before the job gives up on it.
16
19
  export const HELLO_WAIT_MS = 5 * 60 * 1000
20
+ // A machine's rooms move one at a time; its next move waits for a whole minute's report after the last.
21
+ export const MOVE_RETRY_MS = 90 * 1000
22
+ // Between marking an empty machine retiring and destroying it: a placement or move that read it as
23
+ // active a moment ago lands (and cancels the retire) or sees it retiring (and goes elsewhere) first.
24
+ export const RETIRE_GRACE_MS = 60 * 1000
25
+ // How often one API machine looks over the whole cluster (on a machine's report).
26
+ export const TICK_MS = 30 * 1000
27
+ const MIN = 60 * 1000
17
28
  const ROOM = /^[A-Za-z0-9_-]{1,64}$/
18
29
  const MACHINE = /^[A-Za-z0-9_-]{1,64}$/
19
30
 
20
- const alive = (m, t) => m.state === 'active' && Math.max(m.reportedAt || 0, m.helloAt || 0) >= t - ALIVE_MS
31
+ /**
32
+ * The load a shared machine should stay under (settable on the API, relayTargetsFromEnv).
33
+ * Rooms placed (QUILT_RELAY_CAPACITY on the relay) are only a hard ceiling for new rooms.
34
+ */
35
+ export const TARGETS = {
36
+ memory: 0.7, // memory used / the machine's memory
37
+ lagMs: 100, // event-loop lag (the minute's 99th percentile)
38
+ cpu: 0.9, // share of one core over the minute
39
+ hotMinutes: 3, // lag or CPU over target must last this many minutes running
40
+ bytesPerMin: 100 * 1024 * 1024, // WebSocket bytes in and out a minute, every room together
41
+ rebalanceMinutes: 5, // over target this many minutes running: its lightest rooms move off
42
+ emptyMinutes: 30, // a shared machine empty this long is stopped and destroyed
43
+ minMachines: 1, // shared machines always kept
44
+ consolidateBelow: 0.25, // two machines both under this share of their target: one hands its rooms to the other
45
+ moveGapMinutes: 30 // a room never moves again sooner than this
46
+ }
47
+
48
+ const ENV = {
49
+ memory: 'QUILT_RELAY_TARGET_MEMORY',
50
+ lagMs: 'QUILT_RELAY_TARGET_LAG_MS',
51
+ cpu: 'QUILT_RELAY_TARGET_CPU',
52
+ hotMinutes: 'QUILT_RELAY_TARGET_HOT_MINUTES',
53
+ bytesPerMin: 'QUILT_RELAY_TARGET_BYTES_PER_MIN',
54
+ rebalanceMinutes: 'QUILT_RELAY_REBALANCE_MINUTES',
55
+ emptyMinutes: 'QUILT_RELAY_EMPTY_MINUTES',
56
+ minMachines: 'QUILT_RELAY_MIN_MACHINES',
57
+ consolidateBelow: 'QUILT_RELAY_CONSOLIDATE_BELOW',
58
+ moveGapMinutes: 'QUILT_RELAY_MOVE_GAP_MINUTES'
59
+ }
60
+
61
+ /** TARGETS, with any of ENV's variables set to a number (fractions above 0, counts 0 or more) in their place. */
62
+ export function relayTargetsFromEnv (env = process.env) {
63
+ const out = { ...TARGETS }
64
+ for (const [k, name] of Object.entries(ENV)) {
65
+ const raw = String(env[name] ?? '').trim()
66
+ if (!raw) continue
67
+ const v = Number(raw)
68
+ if (!Number.isFinite(v) || v < 0 || (v === 0 && !['minMachines', 'moveGapMinutes'].includes(k))) continue
69
+ out[k] = ['minMachines', 'hotMinutes', 'rebalanceMinutes'].includes(k) ? Math.round(v) : v
70
+ }
71
+ return out
72
+ }
73
+
74
+ /** Takes rooms (and is kept up): reported lately, and active or retiring (a retiring machine still serves what it holds). */
75
+ const up = (m, t) => (m.state === 'active' || m.state === 'retiring') && Math.max(m.reportedAt || 0, m.helloAt || 0) >= t - ALIVE_MS
76
+ const alive = (m, t) => m.state === 'active' && up(m, t)
77
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
78
+
79
+ /**
80
+ * Whether something that started at `since` (a report's time: that report covered the minute before
81
+ * it) has lasted `minutes` by `t`. Time, not a count of reports, so a relay reporting more often
82
+ * than once a minute changes nothing.
83
+ */
84
+ export const lasted = (since, minutes, t) => since != null && t - since + MIN >= minutes * MIN
85
+
86
+ /** Whether a machine's minute had its event loop or CPU over target (it must last `hotMinutes`). */
87
+ export function hotMinute (load = {}, targets = TARGETS) {
88
+ return num(load.lagMs) >= targets.lagMs || (targets.cpu > 0 && num(load.cpu) >= targets.cpu)
89
+ }
90
+
91
+ /** Each part of a machine's load as a share of its target (1 is at target); `rooms` is rooms placed against its capacity. */
92
+ export function loadShares (m, placed = 0, targets = TARGETS) {
93
+ const l = m.load || {}
94
+ const total = num(l.memTotal)
95
+ return {
96
+ memory: total > 0 ? num(l.memUsed) / total / targets.memory : 0,
97
+ lag: num(l.lagMs) / targets.lagMs,
98
+ cpu: targets.cpu > 0 ? num(l.cpu) / targets.cpu : 0,
99
+ io: num(l.bytesPerMin) / targets.bytesPerMin,
100
+ rooms: placed / (m.capacity || 1)
101
+ }
102
+ }
103
+
104
+ /** How loaded a machine is: its most loaded part, as a share of the target. */
105
+ export const loadScore = (m, placed = 0, targets = TARGETS) => Math.max(...Object.values(loadShares(m, placed, targets)))
21
106
 
22
107
  /**
23
- * Where a new room goes: the live shared machine (not dedicated to a room) with the fewest rooms
24
- * loaded among those under their capacity, else the one with the fewest rooms placed. `full`
25
- * says every live shared machine is at capacity (time for another). Null when none is live.
108
+ * Why a machine is over its load target ('memory', 'io', 'lag'), or [] when it isn't. Lag and CPU
109
+ * count once they have lasted `hotMinutes` (since `hotAt`, the report that first had them over) by `t`.
26
110
  */
27
- export function chooseMachine (machines, counts, t) {
28
- const live = machines.filter((m) => !m.dedicatedRoom && alive(m, t))
111
+ export function overReasons (m, targets = TARGETS, t = Date.now()) {
112
+ const s = loadShares(m, 0, targets)
113
+ const out = []
114
+ if (s.memory >= 1) out.push('memory')
115
+ if (s.io >= 1) out.push('io')
116
+ if (lasted(m.hotAt, targets.hotMinutes, t)) out.push('lag')
117
+ return out
118
+ }
119
+
120
+ /**
121
+ * Where a new room goes: the active shared machine (not dedicated, not retiring) under its load
122
+ * target and its room ceiling with the least load, else the least loaded of them all with `full`
123
+ * (every one is over: time for another). Null when none is live. `exclude`: a machine to leave out.
124
+ */
125
+ export function chooseMachine (machines, counts, t, targets = TARGETS, { exclude = null } = {}) {
126
+ const live = machines.filter((m) => !m.dedicatedRoom && m.id !== exclude && alive(m, t))
29
127
  if (!live.length) return null
30
128
  const placed = (m) => counts[m.id] || 0
31
- const under = live.filter((m) => placed(m) < (m.capacity || 1))
129
+ const under = live.filter((m) => placed(m) < (m.capacity || 1) && !overReasons(m, targets, t).length)
32
130
  const pool = under.length ? under : live
33
- const loaded = (m) => Number(m.load && m.load.rooms) || 0
34
- const best = [...pool].sort((a, b) => (under.length ? loaded(a) - loaded(b) : 0) || placed(a) - placed(b) || a.id.localeCompare(b.id))[0]
131
+ const loaded = (m) => num(m.load && m.load.rooms)
132
+ const best = [...pool].sort((a, b) => loadScore(a, placed(a), targets) - loadScore(b, placed(b), targets) || loaded(a) - loaded(b) || placed(a) - placed(b) || a.id.localeCompare(b.id))[0]
35
133
  return { machine: best, full: !under.length }
36
134
  }
37
135
 
136
+ /**
137
+ * The rooms of a machine that may move now, lightest first (bytes a minute, then connections,
138
+ * then size): placed (not mid-move), not moved in the last `moveGapMinutes`, and no git push or
139
+ * commit running in it (its machine's report says).
140
+ */
141
+ export function movableRooms (rooms, t, targets = TARGETS) {
142
+ const s = (r) => r.stats || {}
143
+ return rooms
144
+ .filter((r) => r.state === 'placed' && !(r.movedAt && t - r.movedAt < targets.moveGapMinutes * MIN) && !(num(s(r).git) > 0))
145
+ .sort((a, b) => (num(s(a).bytesIn) + num(s(a).bytesOut)) - (num(s(b).bytesIn) + num(s(b).bytesOut)) || num(s(a).conns) - num(s(b).conns) || num(s(a).docBytes) - num(s(b).docBytes) || a.room.localeCompare(b.room))
146
+ }
147
+
38
148
  /**
39
149
  * @param {object} o
40
150
  * @param {object} o.store the API's store (relay machine and room methods)
41
151
  * @param {string} o.relayUrl the relay's public address (http(s)://)
42
152
  * @param {string} o.relaySecret RELAY_API_SECRET
43
- * @param {object|null} [o.fly] relay-machines.js client, or null: then heavy rooms are only logged
153
+ * @param {object|null} [o.fly] relay-machines.js client, or null: then heavy rooms are only logged, and no machine is added or destroyed
44
154
  * @param {object} [o.limits] what heavy is (relay-metrics.js HEAVY)
155
+ * @param {object} [o.targets] the load a shared machine stays under (TARGETS)
45
156
  */
46
- export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch = globalThis.fetch, now = Date.now, log = () => {}, fly = null, limits = HEAVY, helloWaitMs = HELLO_WAIT_MS, pollMs = 2000, callTimeoutMs = 30 * 1000, moveTimeoutMs = 30 * 60 * 1000, volumeGb = 3 } = {}) {
157
+ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch = globalThis.fetch, now = Date.now, log = () => {}, fly = null, limits = HEAVY, targets = TARGETS, helloWaitMs = HELLO_WAIT_MS, pollMs = 2000, callTimeoutMs = 30 * 1000, moveTimeoutMs = 30 * 60 * 1000, volumeGb = 3, tickMs = TICK_MS, moveRetryMs = MOVE_RETRY_MS, retireGraceMs = RETIRE_GRACE_MS, maxDrainMoves = 20 } = {}) {
158
+ targets = { ...TARGETS, ...(targets || {}) }
47
159
  const base = String(relayUrl).replace(/\/+$/, '').replace(/^ws(s?):\/\//, 'http$1://')
48
160
  const jobs = new Set() // promises of jobs this API machine runs (tests wait on them)
49
161
  const told = new Set() // heavy rooms already logged as having no Fly to move to
162
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
163
+ let lastTick = -Infinity
50
164
 
51
165
  /** One relay machine: the relay's address, with Fly's proxy told which machine. */
52
166
  async function callRelay (machineId, method, path, body, { timeoutMs = callTimeoutMs } = {}) {
@@ -59,14 +173,14 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
59
173
  const text = await res.text()
60
174
  let out = null
61
175
  try { out = text ? JSON.parse(text) : null } catch { out = { text } }
62
- if (!res.ok) throw new Error(`relay ${machineId || ''} ${path} answered ${res.status}${out && (out.error || out.text) ? `: ${out.error || out.text}` : ''}`)
176
+ if (!res.ok) throw Object.assign(new Error(`relay ${machineId || ''} ${path} answered ${res.status}${out && (out.error || out.text) ? `: ${out.error || out.text}` : ''}`), { status: res.status })
63
177
  return out
64
178
  }
65
179
 
66
- /** Machines that are up: every one gets GitHub's nudges. */
180
+ /** Machines that are up (a retiring one too, while it holds rooms): every one gets GitHub's nudges. */
67
181
  async function liveMachines () {
68
182
  const t = now()
69
- return (await store.relayMachines()).filter((m) => alive(m, t))
183
+ return (await store.relayMachines()).filter((m) => up(m, t))
70
184
  }
71
185
 
72
186
  /** A nudge for every machine (each tells the rooms it holds); one plain request while none has said hello. */
@@ -79,6 +193,15 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
79
193
  return ids.length
80
194
  }
81
195
 
196
+ /** A room landed on a retiring machine (on its disk, or nowhere else to go): it stays, and so does the machine. */
197
+ async function keepIfRetiring (machineId) {
198
+ const m = await store.relayMachine(machineId)
199
+ if (m && m.state === 'retiring') {
200
+ await store.putRelayMachine({ id: machineId, state: 'active', emptyAt: null })
201
+ log(`relay ${machineId} holds a room again: it is not retired`)
202
+ }
203
+ }
204
+
82
205
  /** A machine started: it is live, and the rooms on its disk are its own unless placed elsewhere (those it moves aside). */
83
206
  async function hello ({ machine, region = '', capacity, rooms = [] }) {
84
207
  const cur = await store.relayMachine(machine)
@@ -103,32 +226,47 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
103
226
  if (!(await store.relayMachine(machine))) await store.putRelayMachine({ id: machine, state: 'active', helloAt: now(), reportedAt: now() })
104
227
  let target = machine
105
228
  if (!have) {
106
- const pick = chooseMachine(await store.relayMachines(), await store.relayRoomCounts(), now())
229
+ const pick = chooseMachine(await store.relayMachines(), await store.relayRoomCounts(), now(), targets)
107
230
  if (pick) {
108
231
  target = pick.machine.id
109
- if (pick.full) startJob('scale', { region: pick.machine.region }, () => scaleUp(pick.machine))
232
+ if (pick.full) scaleOnce(pick.machine)
110
233
  }
111
234
  }
112
235
  const r = await store.placeRelayRoom(room, target)
236
+ await keepIfRetiring(r.machineId)
113
237
  return { machine: r.machineId, state: r.state }
114
238
  }
115
239
 
116
- /** A machine's minute: its load, and each room's traffic and size; heavy rooms get a machine of their own. */
240
+ /** A machine's minute: its load, and each room's traffic and size; heavy rooms get a machine of their own; then the cluster is looked over. */
117
241
  async function report ({ machine, load = {}, rooms = [] }) {
118
242
  const t = now()
119
- const m = await store.putRelayMachine({ id: machine, load, reportedAt: t, state: 'active' })
243
+ const prev = await store.relayMachine(machine)
244
+ const placedHere = (await store.relayRoomsOn(machine)).length
245
+ const hotAt = hotMinute(load, targets) ? (prev && prev.hotAt) || t : null
246
+ const over = overReasons({ load, hotAt }, targets, t)
247
+ const empty = !placedHere && !(num(load.rooms) > 0)
248
+ const m = await store.putRelayMachine({
249
+ id: machine,
250
+ load,
251
+ reportedAt: t,
252
+ // A retiring machine stays retiring while it reports; any other that reports is up.
253
+ state: prev && prev.state === 'retiring' ? 'retiring' : 'active',
254
+ hotAt,
255
+ overAt: over.length ? (prev && prev.overAt) || t : null,
256
+ emptyAt: empty ? (prev && prev.emptyAt) || t : null
257
+ })
120
258
  let heavy = 0
121
259
  for (const s of rooms) {
122
260
  if (!s || !ROOM.test(String(s.room))) continue
123
261
  const cur = await store.relayRoom(s.room)
124
262
  if (!cur || cur.machineId !== machine) continue
125
- const stats = { conns: s.conns | 0, msgsIn: s.msgsIn | 0, msgsOut: s.msgsOut | 0, bytesIn: +s.bytesIn || 0, bytesOut: +s.bytesOut || 0, docBytes: +s.docBytes || 0, diskBytes: +s.diskBytes || 0 }
263
+ const stats = { conns: s.conns | 0, msgsIn: s.msgsIn | 0, msgsOut: s.msgsOut | 0, bytesIn: +s.bytesIn || 0, bytesOut: +s.bytesOut || 0, docBytes: +s.docBytes || 0, diskBytes: +s.diskBytes || 0, git: s.git | 0 }
126
264
  const busy = busyMinute(stats, limits) ? (cur.busyMinutes || 0) + 1 : 0
127
265
  const why = heavyReasons(stats, limits, busy)
128
266
  await store.setRelayRoom(s.room, { stats, busyMinutes: busy, heavy: why, reportedAt: t })
129
267
  if (why.length && m.dedicatedRoom !== s.room && cur.state === 'placed') { heavy++; onHeavy(s.room, m, why) }
130
268
  }
131
- // A dedicated machine whose room is gone (ended, or swept) takes other rooms again.
269
+ // A dedicated machine whose room is gone (ended, or swept) takes other rooms again (and, left empty, is retired).
132
270
  if (m.dedicatedRoom) {
133
271
  const r = await store.relayRoom(m.dedicatedRoom)
134
272
  const job = await store.relayJob(`dedicate:${m.dedicatedRoom}`)
@@ -137,7 +275,9 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
137
275
  log(`relay ${machine}: ${m.dedicatedRoom} is not on it any more; it takes other rooms now`)
138
276
  }
139
277
  }
140
- return { ok: true, heavy }
278
+ if (over.length && !(prev && prev.overAt)) log(`relay ${machine} is over its load target (${over.join(', ')})`)
279
+ tick()
280
+ return { ok: true, heavy, over }
141
281
  }
142
282
 
143
283
  async function forget ({ room, machine }) {
@@ -145,10 +285,10 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
145
285
  return { ok: true }
146
286
  }
147
287
 
148
- /** Runs `fn` as the job `key` unless it is running (here or on another API machine) or ran just now. */
149
- function startJob (key, detail, fn) {
150
- const p = (async () => {
151
- if (!(await store.takeRelayJob(key, { detail, staleMs: JOB_STALE_MS, retryMs: JOB_RETRY_MS }))) return null
288
+ /** Runs `fn` as the job `key` unless it is running (here or on another API machine) or ran within `retryMs`. */
289
+ function startJob (key, detail, fn, { retryMs = JOB_RETRY_MS } = {}) {
290
+ return track((async () => {
291
+ if (!(await store.takeRelayJob(key, { detail, staleMs: JOB_STALE_MS, retryMs }))) return null
152
292
  try {
153
293
  const out = await fn()
154
294
  await store.finishRelayJob(key, { state: 'done', detail: { ...detail, ...(out || {}) } })
@@ -158,7 +298,10 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
158
298
  await store.finishRelayJob(key, { state: 'failed', detail: { ...detail, error: String(err.message).slice(0, 300) } }).catch(() => {})
159
299
  return null
160
300
  }
161
- })().catch((err) => { log(`relay job ${key}: ${err.message}`); return null })
301
+ })().catch((err) => { log(`relay job ${key}: ${err.message}`); return null }))
302
+ }
303
+
304
+ function track (p) {
162
305
  jobs.add(p)
163
306
  p.finally(() => jobs.delete(p))
164
307
  return p
@@ -177,32 +320,42 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
177
320
  const others = (await store.relayRoomsOn(from.id)).filter((r) => r.room !== room)
178
321
  if (!others.length) {
179
322
  // Alone on its machine already: that machine is now its own, and takes no others.
180
- await store.putRelayMachine({ id: from.id, dedicatedRoom: room })
323
+ await store.putRelayMachine({ id: from.id, dedicatedRoom: room, state: 'active', emptyAt: null })
181
324
  log(`room ${room} keeps relay ${from.id} to itself`)
182
325
  return { machine: from.id, moved: false }
183
326
  }
184
327
  // A machine already up with no rooms (one an earlier, failed move made, or a spare shared one)
185
328
  // goes first: a retry never leaves the last try's machine running empty.
186
329
  const spare = await spareMachine(room, from.id)
187
- if (spare) await store.putRelayMachine({ id: spare, dedicatedRoom: room })
330
+ if (spare) await store.putRelayMachine({ id: spare, dedicatedRoom: room, emptyAt: null })
188
331
  const made = spare || await newMachine({ region: from.region, dedicatedRoom: room, template: from.id })
189
- await moveRoom(room, from.id, made)
332
+ const r = await moveRoom(room, from.id, made)
333
+ if (!r.moved) throw new Error(`room ${room} could not move now (${r.why})`)
190
334
  return { machine: made, moved: true }
191
335
  }
192
336
 
193
- /** A live machine other than `fromId` holding no rooms, shared or kept for `room`; null when none. */
337
+ /** An active machine other than `fromId` holding no rooms, shared or kept for `room`; null when none. */
194
338
  async function spareMachine (room, fromId) {
195
- for (const m of await liveMachines()) {
196
- if (m.id === fromId || (m.dedicatedRoom && m.dedicatedRoom !== room)) continue
339
+ const t = now()
340
+ for (const m of await store.relayMachines()) {
341
+ if (!alive(m, t) || m.id === fromId || (m.dedicatedRoom && m.dedicatedRoom !== room)) continue
197
342
  if (!(await store.relayRoomsOn(m.id)).length) return m.id
198
343
  }
199
344
  return null
200
345
  }
201
346
 
202
- /** Every shared machine is at capacity: one more shared machine. */
347
+ /** Every shared machine is over its target: one more shared machine (one at a time, the 'scale' job). */
348
+ function scaleOnce (like) {
349
+ if (!fly) {
350
+ if (!told.has(':scale')) { told.add(':scale'); log('every shared relay machine is over its load target; no Fly token, so no machine is added') }
351
+ return null
352
+ }
353
+ return startJob('scale', { region: like.region }, () => scaleUp(like))
354
+ }
355
+
203
356
  async function scaleUp (like) {
204
357
  const id = await newMachine({ region: like.region, dedicatedRoom: null, template: like.id })
205
- log(`added relay ${id}: every shared machine was at capacity`)
358
+ log(`added relay ${id}: every shared machine was over its load target`)
206
359
  return { machine: id }
207
360
  }
208
361
 
@@ -215,22 +368,33 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
215
368
  const row = await store.relayMachine(m.id)
216
369
  if (row && row.helloAt) return m.id
217
370
  if (now() >= until) throw new Error(`relay machine ${m.id} did not start in ${Math.round(helloWaitMs / 1000)} s`)
218
- await new Promise((resolve) => setTimeout(resolve, pollMs))
371
+ await sleep(pollMs)
219
372
  }
220
373
  }
221
374
 
222
375
  /**
223
376
  * Moves `room` from machine `from` to `to`: the source lets it go (its people reconnect), the
224
377
  * target copies its files from the source, placement flips, the source forgets its copy. Until
225
- * the flip, a failure puts the room back where it was.
378
+ * the flip, a failure puts the room back where it was. { moved: false, why } when the source
379
+ * would not let it go now (a git push or commit running in it) or the target stopped taking rooms.
226
380
  */
227
381
  async function moveRoom (room, from, to) {
228
382
  await store.setRelayRoom(room, { state: 'moving' })
229
- let flipped = false
230
383
  try {
231
384
  await callRelay(from, 'POST', `/cluster/rooms/${room}/release`, {})
385
+ } catch (err) {
386
+ await store.setRelayRoom(room, { machineId: from, state: 'placed' }).catch(() => {})
387
+ if (err.status === 409) { log(`room ${room} stays on relay ${from} for now: ${err.message}`); return { moved: false, why: 'busy' } }
388
+ await callRelay(from, 'POST', `/cluster/rooms/${room}/restore`, {}).catch((e) => log(`could not restore ${room} on ${from}: ${e.message}`))
389
+ throw err
390
+ }
391
+ let flipped = false
392
+ try {
232
393
  const got = await callRelay(to, 'POST', `/cluster/rooms/${room}/fetch`, { from }, { timeoutMs: moveTimeoutMs })
233
- await store.setRelayRoom(room, { machineId: to, state: 'placed', busyMinutes: 0 })
394
+ // The target may have started retiring while the copy ran: then the room stays where it was.
395
+ const target = await store.relayMachine(to)
396
+ if (!target || target.state !== 'active') throw Object.assign(new Error(`relay ${to} is ${target ? target.state : 'gone'}`), { notTaking: true })
397
+ await store.setRelayRoom(room, { machineId: to, state: 'placed', busyMinutes: 0, movedAt: now() })
234
398
  flipped = true
235
399
  log(`moved room ${room} from relay ${from} to ${to} (${got && got.files} files, ${got && got.bytes} bytes)`)
236
400
  } catch (err) {
@@ -239,13 +403,143 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
239
403
  await callRelay(from, 'POST', `/cluster/rooms/${room}/restore`, {}).catch((e) => log(`could not restore ${room} on ${from}: ${e.message}`))
240
404
  await callRelay(to, 'POST', `/cluster/rooms/${room}/forget`, { to: from }).catch(() => {})
241
405
  }
406
+ if (err.notTaking) { log(`room ${room} stays on relay ${from}: ${err.message}`); return { moved: false, why: 'target' } }
242
407
  throw err
243
408
  }
244
409
  await callRelay(from, 'POST', `/cluster/rooms/${room}/forget`, { to }).catch((err) => log(`relay ${from} could not delete its copy of ${room}: ${err.message}`))
410
+ return { moved: true }
411
+ }
412
+
413
+ // ---- scaling both ways, on load ----
414
+
415
+ /** At most every `tickMs` (per API machine), on a machine's report: the whole cluster is looked over. */
416
+ function tick () {
417
+ const t = now()
418
+ if (t - lastTick < tickMs) return null
419
+ lastTick = t
420
+ return track(autoscale().catch((err) => { log(`relay autoscale: ${err.message}`); return null }))
421
+ }
422
+
423
+ async function autoscale () {
424
+ const t = now()
425
+ const machines = await store.relayMachines()
426
+ const counts = await store.relayRoomCounts()
427
+ const placed = (m) => counts[m.id] || 0
428
+ const shared = machines.filter((m) => !m.dedicatedRoom && up(m, t))
429
+ const active = shared.filter((m) => m.state === 'active')
430
+ // Shared machines staying: retiring one needs this to stay at least the minimum.
431
+ let keep = active.length
432
+
433
+ // A retiring machine still holding rooms hands them on, one at a time.
434
+ for (const m of shared) if (m.state === 'retiring' && placed(m)) startJob(`move:${m.id}`, { machine: m.id, why: 'retiring' }, () => drain(m.id), { retryMs: moveRetryMs })
435
+
436
+ // Empty long enough: stopped and destroyed with its volume, keeping the minimum.
437
+ for (const m of shared) {
438
+ if (placed(m) || num(m.load && m.load.rooms) > 0 || !m.emptyAt || t - m.emptyAt < targets.emptyMinutes * MIN) continue
439
+ if (!fly) {
440
+ if (!told.has(`:empty:${m.id}`)) { told.add(`:empty:${m.id}`); log(`relay ${m.id} has been empty for ${targets.emptyMinutes} minutes; no Fly token, so it keeps running`) }
441
+ continue
442
+ }
443
+ if (m.state === 'active') {
444
+ if (keep - 1 < targets.minMachines) continue
445
+ keep--
446
+ } else if (keep < targets.minMachines) {
447
+ await store.putRelayMachine({ id: m.id, state: 'active' })
448
+ keep++
449
+ continue
450
+ }
451
+ startJob(`retire:${m.id}`, { machine: m.id }, () => retire(m.id))
452
+ }
453
+
454
+ // Over target for a while, with rooms to spare: its lightest rooms move off, one at a time.
455
+ for (const m of active) {
456
+ if (placed(m) < 2 || !lasted(m.overAt, targets.rebalanceMinutes, t)) continue
457
+ startJob(`move:${m.id}`, { machine: m.id, why: 'over target' }, () => rebalance(m.id), { retryMs: moveRetryMs })
458
+ }
459
+
460
+ // Two (or more) lightly loaded machines that have been up a while: the emptier hands its rooms
461
+ // to the others and, once empty, is retired. One at a time, never below the minimum.
462
+ if (fly && !shared.some((m) => m.state === 'retiring') && active.length > Math.max(1, targets.minMachines)) {
463
+ const light = active.filter((m) => placed(m) > 0 && !m.overAt && (m.helloAt || 0) <= t - targets.emptyMinutes * MIN && loadScore(m, placed(m), targets) < targets.consolidateBelow)
464
+ if (light.length >= 2) {
465
+ const [from] = light.sort((a, b) => placed(a) - placed(b) || loadScore(a, placed(a), targets) - loadScore(b, placed(b), targets) || (b.createdAt || 0) - (a.createdAt || 0) || b.id.localeCompare(a.id))
466
+ await store.putRelayMachine({ id: from.id, state: 'retiring' })
467
+ log(`relay ${from.id} is lightly loaded, and so is another: its ${placed(from)} room(s) move off and it is retired`)
468
+ startJob(`move:${from.id}`, { machine: from.id, why: 'consolidating' }, () => drain(from.id), { retryMs: moveRetryMs })
469
+ }
470
+ }
471
+ }
472
+
473
+ /** A machine over its target: its lightest movable room goes to the least loaded machine under target, else a new machine is asked for. */
474
+ async function rebalance (id) {
475
+ const m = await store.relayMachine(id)
476
+ if (!m || m.state !== 'active' || m.dedicatedRoom || !lasted(m.overAt, targets.rebalanceMinutes, now())) return { skipped: 'not over target' }
477
+ const all = await store.relayRoomsOn(id)
478
+ const rooms = movableRooms(all, now(), targets).filter((r) => !(r.heavy && r.heavy.length))
479
+ if (all.length < 2 || !rooms.length) return { skipped: 'no room may move now' }
480
+ const pick = chooseMachine(await store.relayMachines(), await store.relayRoomCounts(), now(), targets, { exclude: id })
481
+ if (!pick || pick.full) {
482
+ scaleOnce(m)
483
+ return { waiting: 'a machine under target' }
484
+ }
485
+ for (const r of rooms) {
486
+ const out = await moveRoom(r.room, id, pick.machine.id)
487
+ if (out.moved) return { room: r.room, to: pick.machine.id }
488
+ if (out.why === 'target') break
489
+ }
490
+ return { skipped: 'no room could move now' }
491
+ }
492
+
493
+ /** A retiring machine's rooms go to the least loaded machines under target; with none, it is not retired after all. */
494
+ async function drain (id) {
495
+ const moved = []
496
+ const skip = new Set()
497
+ for (let i = 0; i < maxDrainMoves; i++) {
498
+ const m = await store.relayMachine(id)
499
+ if (!m || m.state !== 'retiring') break
500
+ const rooms = movableRooms(await store.relayRoomsOn(id), now(), targets).filter((r) => !skip.has(r.room))
501
+ if (!rooms.length) break
502
+ const pick = chooseMachine(await store.relayMachines(), await store.relayRoomCounts(), now(), targets, { exclude: id })
503
+ if (!pick || pick.full) {
504
+ await store.putRelayMachine({ id, state: 'active' })
505
+ log(`relay ${id} keeps its rooms: no other machine is under its load target`)
506
+ return { moved, cancelled: true }
507
+ }
508
+ const out = await moveRoom(rooms[0].room, id, pick.machine.id)
509
+ if (out.moved) moved.push(rooms[0].room); else skip.add(rooms[0].room)
510
+ }
511
+ return { moved }
512
+ }
513
+
514
+ /** An empty shared machine: retiring (no new rooms), then stopped and destroyed with its volume, unless a room lands on it first. */
515
+ async function retire (id) {
516
+ const m = await store.relayMachine(id)
517
+ if (!m || m.dedicatedRoom || !up(m, now())) return { skipped: 'not a live shared machine' }
518
+ if ((await store.relayRoomsOn(id)).length) return { skipped: 'holds rooms' }
519
+ const others = (await store.relayMachines()).filter((x) => x.id !== id && !x.dedicatedRoom && alive(x, now()))
520
+ if (others.length < targets.minMachines) {
521
+ if (m.state === 'retiring') await store.putRelayMachine({ id, state: 'active' })
522
+ return { skipped: 'the last machines are kept' }
523
+ }
524
+ if (m.state !== 'retiring') await store.putRelayMachine({ id, state: 'retiring' })
525
+ if (retireGraceMs > 0) await sleep(retireGraceMs)
526
+ const cur = await store.relayMachine(id)
527
+ if (!cur || cur.state !== 'retiring' || (await store.relayRoomsOn(id)).length) {
528
+ if (cur && cur.state === 'retiring') await store.putRelayMachine({ id, state: 'active', emptyAt: null })
529
+ log(`relay ${id} is not retired: a room landed on it`)
530
+ return { cancelled: true }
531
+ }
532
+ const out = await fly.destroyRelayMachine(id)
533
+ await store.putRelayMachine({ id, state: 'gone', emptyAt: null, hotAt: null, overAt: null })
534
+ // A room placed on it in the last moment had nothing there yet: it is placed afresh when next asked for.
535
+ for (const r of await store.relayRoomsOn(id)) { await store.forgetRelayRoom(r.room, id); log(`room ${r.room} was placed on relay ${id} as it was destroyed; it is placed again when next asked for`) }
536
+ log(`retired relay ${id}: empty for ${targets.emptyMinutes} minutes (destroyed with ${(out && out.volumes && out.volumes.length) || 0} volume(s))`)
537
+ for (const v of (out && out.leftover) || []) log(`relay ${id} is destroyed, but Fly would not delete its volume ${v.volume} (${v.error}): delete it by hand`)
538
+ return { destroyed: true, volumes: (out && out.volumes) || [], ...(out && out.leftover && out.leftover.length ? { leftover: out.leftover.map((v) => v.volume) } : {}) }
245
539
  }
246
540
 
247
541
  /** Waits for every job started so far (tests). */
248
542
  async function idle () { while (jobs.size) await Promise.allSettled([...jobs]) }
249
543
 
250
- return { hello, place, report, forget, tellRelays, callRelay, moveRoom, dedicate, idle, valid: { room: (r) => ROOM.test(String(r)), machine: (m) => MACHINE.test(String(m)) } }
544
+ return { hello, place, report, forget, tellRelays, callRelay, moveRoom, dedicate, autoscale, rebalance, drain, retire, idle, targets, valid: { room: (r) => ROOM.test(String(r)), machine: (m) => MACHINE.test(String(m)) } }
251
545
  }
@@ -1,6 +1,7 @@
1
1
  // Fly's Machines API, for the relay's machines (src/api/relay-cluster.js): a new relay machine is
2
2
  // a copy of a running one's config (image, size, services, checks, env), with a volume of its own
3
3
  // at the same path. `fly deploy` updates it with the others: it keeps the app's process group.
4
+ // A machine the cluster no longer needs (empty a while) is stopped and destroyed with its volume.
4
5
  // Docs: https://fly.io/docs/machines/api/
5
6
  export const FLY_API = 'https://api.machines.dev/v1'
6
7
 
@@ -14,7 +15,7 @@ export function relayFlyFromEnv (env = process.env, o = {}) {
14
15
  return relayFlyMachines({ token, app: env.QUILT_RELAY_APP || 'cowove-relay', ...o })
15
16
  }
16
17
 
17
- export function relayFlyMachines ({ token, app, fetch = globalThis.fetch, base = FLY_API, timeoutMs = 60 * 1000 }) {
18
+ export function relayFlyMachines ({ token, app, fetch = globalThis.fetch, base = FLY_API, timeoutMs = 60 * 1000, volumeWaitMs = 2 * 60 * 1000, pollMs = 3000 }) {
18
19
  const root = String(base).replace(/\/+$/, '')
19
20
  const call = async (method, path, body) => {
20
21
  const res = await fetch(`${root}${path}`, {
@@ -26,7 +27,7 @@ export function relayFlyMachines ({ token, app, fetch = globalThis.fetch, base =
26
27
  const text = await res.text()
27
28
  let out = null
28
29
  try { out = text ? JSON.parse(text) : null } catch { out = { error: text } }
29
- if (!res.ok) throw new Error(`Fly answered ${res.status} for ${method} ${path.split('?')[0]}${out && out.error ? `: ${String(out.error).slice(0, 200)}` : ''}`)
30
+ if (!res.ok) throw Object.assign(new Error(`Fly answered ${res.status} for ${method} ${path.split('?')[0]}${out && out.error ? `: ${String(out.error).slice(0, 200)}` : ''}`), { status: res.status })
30
31
  return out
31
32
  }
32
33
  const appPath = `/apps/${encodeURIComponent(app)}`
@@ -56,5 +57,36 @@ export function relayFlyMachines ({ token, app, fetch = globalThis.fetch, base =
56
57
  return { id: m.id, region: m.region || where, volume: vol.id }
57
58
  }
58
59
 
59
- return { createRelayMachine, app }
60
+ /**
61
+ * Stops relay machine `id`, destroys it, then deletes its volumes (Fly frees a volume a moment after
62
+ * its machine goes, so that is tried until `volumeWaitMs`). A machine Fly no longer has is done
63
+ * already. Resolves { id, volumes, leftover }: the volumes deleted, and any Fly would not delete
64
+ * in time (the machine is gone either way; those are for someone to delete by hand).
65
+ */
66
+ async function destroyRelayMachine (id) {
67
+ const mp = `${appPath}/machines/${encodeURIComponent(id)}`
68
+ let m = null
69
+ try { m = await call('GET', mp) } catch (err) { if (err.status !== 404) throw err }
70
+ const volumes = ((m && m.config && Array.isArray(m.config.mounts) && m.config.mounts) || []).map((x) => x && x.volume).filter(Boolean)
71
+ if (m) {
72
+ await call('POST', `${mp}/stop`, {}).catch(() => {})
73
+ await call('GET', `${mp}/wait?state=stopped&timeout=60`).catch(() => {})
74
+ try { await call('DELETE', `${mp}?force=true`) } catch (err) { if (err.status !== 404) throw err }
75
+ }
76
+ const until = Date.now() + volumeWaitMs
77
+ const deleted = []
78
+ const leftover = []
79
+ for (const v of volumes) {
80
+ for (;;) {
81
+ try { await call('DELETE', `${appPath}/volumes/${encodeURIComponent(v)}`); deleted.push(v); break } catch (err) {
82
+ if (err.status === 404) { deleted.push(v); break }
83
+ if (Date.now() >= until) { leftover.push({ volume: v, error: err.message }); break }
84
+ await new Promise((resolve) => setTimeout(resolve, pollMs))
85
+ }
86
+ }
87
+ }
88
+ return { id, volumes: deleted, leftover }
89
+ }
90
+
91
+ return { createRelayMachine, destroyRelayMachine, app }
60
92
  }