heyquilt 0.0.1 → 0.3.90

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 (204) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +630 -2
  3. package/RELEASES.md +733 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +816 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +563 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +180 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +170 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +98 -0
  19. package/src/ai-vm.js +380 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +130 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +76 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1083 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +251 -0
  40. package/src/api/relay-machines.js +60 -0
  41. package/src/api/routes/access-types.js +54 -0
  42. package/src/api/routes/agent-invites.js +115 -0
  43. package/src/api/routes/agents.js +157 -0
  44. package/src/api/routes/ai-vm.js +369 -0
  45. package/src/api/routes/github.js +380 -0
  46. package/src/api/routes/grants.js +70 -0
  47. package/src/api/routes/invites.js +188 -0
  48. package/src/api/routes/issues.js +47 -0
  49. package/src/api/routes/join.js +167 -0
  50. package/src/api/routes/members.js +110 -0
  51. package/src/api/routes/orgs.js +177 -0
  52. package/src/api/routes/relay-cluster.js +90 -0
  53. package/src/api/routes/relay.js +76 -0
  54. package/src/api/routes/session-invites.js +119 -0
  55. package/src/api/routes/sessions.js +92 -0
  56. package/src/api/routes/teams.js +132 -0
  57. package/src/api/routes/workspace-agents.js +332 -0
  58. package/src/api/routes/workspace-files.js +277 -0
  59. package/src/api/routes/workspace-invites.js +196 -0
  60. package/src/api/routes/workspaces.js +225 -0
  61. package/src/api/server.js +623 -0
  62. package/src/api/slugs.js +20 -0
  63. package/src/api/supabase-store.js +765 -0
  64. package/src/api/team-access.js +30 -0
  65. package/src/api/tokens.js +31 -0
  66. package/src/api/workspace-access.js +62 -0
  67. package/src/api/workspace-reach.js +60 -0
  68. package/src/autowork-tools.js +166 -0
  69. package/src/autowork.js +387 -0
  70. package/src/blobstore.js +99 -0
  71. package/src/boxes.js +37 -0
  72. package/src/branchdocs.js +235 -0
  73. package/src/branches.js +194 -0
  74. package/src/catchup.js +111 -0
  75. package/src/chat-archive.js +85 -0
  76. package/src/chat-links.js +923 -0
  77. package/src/clash.js +140 -0
  78. package/src/cloud-ai.js +50 -0
  79. package/src/commit-request.js +59 -0
  80. package/src/commit.js +213 -0
  81. package/src/connection.js +569 -0
  82. package/src/control.js +245 -0
  83. package/src/conversation.js +125 -0
  84. package/src/doctor.js +156 -0
  85. package/src/duties.js +303 -0
  86. package/src/editors.js +228 -0
  87. package/src/fsutil.js +87 -0
  88. package/src/git-hook.js +48 -0
  89. package/src/git.js +147 -0
  90. package/src/github-commit.js +320 -0
  91. package/src/gitignore.js +49 -0
  92. package/src/gitstate.js +900 -0
  93. package/src/history.js +307 -0
  94. package/src/hooks.js +371 -0
  95. package/src/hosted-agents.js +24 -0
  96. package/src/hosted-client.js +112 -0
  97. package/src/identity.js +99 -0
  98. package/src/inbox.js +237 -0
  99. package/src/integrations.js +221 -0
  100. package/src/key-cli.js +115 -0
  101. package/src/largefiles.js +91 -0
  102. package/src/legacy.js +25 -0
  103. package/src/mcp-proxy.js +40 -0
  104. package/src/mcp-room.js +32 -0
  105. package/src/mcp.js +1261 -0
  106. package/src/merge3.js +158 -0
  107. package/src/merges.js +148 -0
  108. package/src/pass-source.js +176 -0
  109. package/src/passes.js +64 -0
  110. package/src/pathrules.js +127 -0
  111. package/src/persona.js +101 -0
  112. package/src/presence.js +360 -0
  113. package/src/private-file.js +41 -0
  114. package/src/procs.js +70 -0
  115. package/src/project-rules.js +178 -0
  116. package/src/protocol.js +92 -0
  117. package/src/quiet-warnings.js +9 -0
  118. package/src/relay-ai-vm.js +82 -0
  119. package/src/relay-ask.js +141 -0
  120. package/src/relay-ci.js +111 -0
  121. package/src/relay-clash.js +597 -0
  122. package/src/relay-cluster.js +454 -0
  123. package/src/relay-commit.js +887 -0
  124. package/src/relay-filekey.js +46 -0
  125. package/src/relay-git.js +274 -0
  126. package/src/relay-githttp.js +137 -0
  127. package/src/relay-github.js +109 -0
  128. package/src/relay-lease.js +137 -0
  129. package/src/relay-lfs.js +37 -0
  130. package/src/relay-mcp.js +1679 -0
  131. package/src/relay-metrics.js +75 -0
  132. package/src/relay-outbound.js +1144 -0
  133. package/src/relay-sync.js +719 -0
  134. package/src/relay-turn-sync.js +307 -0
  135. package/src/relay-upstream.js +934 -0
  136. package/src/relay-webhooks.js +111 -0
  137. package/src/releases.js +131 -0
  138. package/src/report.js +129 -0
  139. package/src/runner.js +306 -0
  140. package/src/same-edit.js +18 -0
  141. package/src/server.js +4187 -0
  142. package/src/session-access.js +170 -0
  143. package/src/session-name.js +15 -0
  144. package/src/session.js +7361 -0
  145. package/src/settings.js +46 -0
  146. package/src/setup.js +184 -0
  147. package/src/shell-path.js +46 -0
  148. package/src/status.js +199 -0
  149. package/src/summarize.js +109 -0
  150. package/src/sync-words.js +42 -0
  151. package/src/task-comments.js +92 -0
  152. package/src/tasks.js +670 -0
  153. package/src/textdiff.js +20 -0
  154. package/src/turn-sync-plan.js +142 -0
  155. package/src/turn-sync.js +191 -0
  156. package/src/ui/access-form.js +37 -0
  157. package/src/ui/agent-env-key.js +76 -0
  158. package/src/ui/agent-guide.js +127 -0
  159. package/src/ui/agent-kinds.js +175 -0
  160. package/src/ui/agent-place.js +265 -0
  161. package/src/ui/app.css +2309 -0
  162. package/src/ui/app.js +542 -0
  163. package/src/ui/board.js +465 -0
  164. package/src/ui/boxes.js +76 -0
  165. package/src/ui/branches.js +111 -0
  166. package/src/ui/catchup.js +77 -0
  167. package/src/ui/changes.js +159 -0
  168. package/src/ui/chat.js +266 -0
  169. package/src/ui/cloud-ai-model.js +107 -0
  170. package/src/ui/cloud-ai.js +221 -0
  171. package/src/ui/common.js +431 -0
  172. package/src/ui/csv.js +21 -0
  173. package/src/ui/diffview.js +74 -0
  174. package/src/ui/feed-convs.js +39 -0
  175. package/src/ui/feed.js +174 -0
  176. package/src/ui/files.js +579 -0
  177. package/src/ui/fileview.js +123 -0
  178. package/src/ui/git-rule.js +60 -0
  179. package/src/ui/home.js +1323 -0
  180. package/src/ui/index.html +16 -0
  181. package/src/ui/invite.js +97 -0
  182. package/src/ui/invites.js +79 -0
  183. package/src/ui/loom-model.js +516 -0
  184. package/src/ui/loom.css +349 -0
  185. package/src/ui/loom.js +753 -0
  186. package/src/ui/mark.js +104 -0
  187. package/src/ui/merges.js +158 -0
  188. package/src/ui/rail-model.js +78 -0
  189. package/src/ui/rail.js +173 -0
  190. package/src/ui/releases.js +182 -0
  191. package/src/ui/schedule.js +156 -0
  192. package/src/ui/session.js +3394 -0
  193. package/src/ui/signin.js +75 -0
  194. package/src/ui/sounds.js +110 -0
  195. package/src/ui/tool-logo.js +86 -0
  196. package/src/ui/tree.js +276 -0
  197. package/src/ui/workspaces.js +542 -0
  198. package/src/ui-server.js +1272 -0
  199. package/src/update-check.js +67 -0
  200. package/src/upstream.js +155 -0
  201. package/src/webhooks.js +235 -0
  202. package/src/workspace-cli.js +62 -0
  203. package/src/workspace-tools.js +340 -0
  204. package/index.js +0 -2
@@ -0,0 +1,454 @@
1
+ // The relay on many machines (docs/superpowers/specs/2026-10-17-relay-scaling.md). Each room lives
2
+ // on one machine; the accounts API says which (src/api/relay-cluster.js). A machine that gets a
3
+ // request for a room it does not hold answers `fly-replay: instance=<machine>` and Fly's proxy
4
+ // sends the request there instead; a body too big for Fly to replay (over 1 MB, or of unknown
5
+ // length) is streamed to that machine over Fly's private network. The API reaches one machine
6
+ // with `fly-force-instance-id`. Machines say hello when they start and report every minute.
7
+ //
8
+ // Off unless QUILT_CLUSTER=1: one machine, as before. On, it needs the machine id (FLY_MACHINE_ID),
9
+ // the accounts API (QUILT_API_URL, RELAY_API_SECRET), sign-in, and with git sync the one file key
10
+ // every machine shares (QUILT_RELAY_FILE_KEY).
11
+ import fs from 'node:fs'
12
+ import path from 'node:path'
13
+ import http from 'node:http'
14
+ import https from 'node:https'
15
+
16
+ // A room placed elsewhere is asked about again after this; one placed here stays here until the API moves it.
17
+ export const PLACEMENT_TTL_MS = 30 * 1000
18
+ // Fly's proxy replays a request's body up to this size.
19
+ export const REPLAY_MAX_BODY = 1024 * 1024
20
+ export const REPORT_MS = 60 * 1000
21
+ // A hosted agent's sessions and webhook from the API, kept this long.
22
+ export const INDEX_TTL_MS = 30 * 1000
23
+ export const SAME_OWNER_TTL_MS = 5 * 60 * 1000
24
+ const API_TIMEOUT_MS = 10 * 1000
25
+ const ROOM_RE = /^[A-Za-z0-9_-]{1,64}$/
26
+ const MACHINE_RE = /^[A-Za-z0-9_-]{1,64}$/
27
+ // Files in the data folder that look like rooms (`<name>.json`) and aren't.
28
+ export const NOT_ROOMS = new Set(['agent-links', 'hosted-agents', 'leases'])
29
+ export const MOVING = 'This session is moving to another relay machine; try again in a moment'
30
+ export const ELSEWHERE = 'This session lives on another relay machine; try again in a moment'
31
+ export const NO_PLACEMENT = "The relay can't find which machine holds this session right now; try again in a moment"
32
+
33
+ /** Cluster settings, from options or the environment (Fly sets FLY_MACHINE_ID, FLY_REGION, FLY_APP_NAME). */
34
+ export function clusterConfig (opts = {}, env = process.env) {
35
+ const on = opts.cluster ?? /^(1|true|yes)$/i.test(String(env.QUILT_CLUSTER || ''))
36
+ const app = env.FLY_APP_NAME || ''
37
+ const port = env.PORT || '4321'
38
+ const capacity = Number(opts.capacity ?? env.QUILT_RELAY_CAPACITY ?? 50)
39
+ return {
40
+ on: !!on,
41
+ machineId: String(opts.machineId ?? env.FLY_MACHINE_ID ?? ''),
42
+ region: String(opts.region ?? env.FLY_REGION ?? ''),
43
+ capacity: Number.isInteger(capacity) && capacity > 0 ? capacity : 50,
44
+ // Another machine of this app, over Fly's private network; with no {id} in it, the address is
45
+ // used as it is, with fly-force-instance-id (a proxy that honours it, as tests use).
46
+ peerUrl: String(opts.peerUrl ?? env.QUILT_PEER_URL ?? (app ? `http://{id}.vm.${app}.internal:${port}` : '')),
47
+ reportMs: Number(opts.reportMs ?? env.QUILT_CLUSTER_REPORT_MS) > 0 ? Number(opts.reportMs ?? env.QUILT_CLUSTER_REPORT_MS) : REPORT_MS,
48
+ placementTtlMs: Number(opts.placementTtlMs) > 0 ? Number(opts.placementTtlMs) : PLACEMENT_TTL_MS
49
+ }
50
+ }
51
+
52
+ /** What cluster mode is missing; [] when it can run. */
53
+ export function clusterProblems (cc, cfg, { fileKey = '' } = {}) {
54
+ if (!cc.on) return []
55
+ const out = []
56
+ if (!MACHINE_RE.test(cc.machineId)) out.push('FLY_MACHINE_ID (this machine\'s id)')
57
+ if (!cfg.apiUrl) out.push('QUILT_API_URL')
58
+ if (!cfg.relayApiSecret) out.push('RELAY_API_SECRET')
59
+ if (!cfg.passPublicKey) out.push('QUILT_PASS_PUBLIC_KEY (sign-in)')
60
+ if (cfg.gitSync && !fileKey) out.push('QUILT_RELAY_FILE_KEY (the file key every machine shares)')
61
+ if (!cc.peerUrl) out.push('QUILT_PEER_URL or FLY_APP_NAME (how machines reach each other)')
62
+ return out
63
+ }
64
+
65
+ /** The rooms kept in a data folder (their `.json` or `.ydoc`). */
66
+ export function storedRooms (dataDir) {
67
+ if (!dataDir) return []
68
+ const out = new Set()
69
+ let files = []
70
+ try { files = fs.readdirSync(dataDir) } catch { return [] }
71
+ for (const f of files) {
72
+ const m = /^(.+)\.(json|ydoc)$/.exec(f)
73
+ if (m && ROOM_RE.test(m[1]) && !NOT_ROOMS.has(m[1])) out.add(m[1])
74
+ }
75
+ return [...out].sort()
76
+ }
77
+
78
+ /** The room a request's path is about ('' for none): git, chat files, large files, chat links. */
79
+ export function roomOfPath (pathname) {
80
+ const m = /^\/git\/([A-Za-z0-9_-]{1,64})\.git\//.exec(pathname) ||
81
+ /^\/files\/([A-Za-z0-9_-]{1,64})(?:\/|$)/.exec(pathname) ||
82
+ /^\/blobs\/([A-Za-z0-9_-]{1,64})\//.exec(pathname) ||
83
+ /^\/c\/([A-Za-z0-9_-]{1,64})\//.exec(pathname)
84
+ return m ? m[1] : ''
85
+ }
86
+
87
+ /** The machine a request was already sent on from (Fly's replay, or another machine's forward), or ''. */
88
+ export function replaySource (req) {
89
+ const fly = /(?:^|;)\s*instance=([A-Za-z0-9_-]+)/.exec(String(req.headers['fly-replay-src'] || ''))
90
+ if (fly) return fly[1]
91
+ const fwd = String(req.headers['x-quilt-forwarded'] || '')
92
+ return MACHINE_RE.test(fwd) ? fwd : ''
93
+ }
94
+
95
+ /** Whether Fly can replay this request (its body, if any, is known to be at most 1 MB). */
96
+ export function canReplay (req, readBody = null) {
97
+ if (readBody) return readBody.length <= REPLAY_MAX_BODY
98
+ if (req.method === 'GET' || req.method === 'HEAD') return true
99
+ if (req.headers['transfer-encoding']) return false
100
+ const n = Number(req.headers['content-length'] || 0)
101
+ return Number.isFinite(n) && n <= REPLAY_MAX_BODY
102
+ }
103
+
104
+ export class Cluster {
105
+ constructor ({ cc, apiUrl, secret, fetch = globalThis.fetch, log = () => {}, dataDir = null, now = Date.now }) {
106
+ this.cc = cc
107
+ this.machineId = cc.machineId
108
+ this.api = String(apiUrl).replace(/\/+$/, '')
109
+ this.secret = secret
110
+ this.fetch = fetch
111
+ this.log = log
112
+ this.dataDir = dataDir
113
+ this.now = now
114
+ this.here = new Set() // rooms the API placed on this machine
115
+ this.elsewhere = new Map() // room -> { machine, at }
116
+ this.moving = new Set() // rooms being moved off this machine: refused until forgotten or restored
117
+ this.hosted = new Map() // account -> { rooms, webhook, at }
118
+ this.owners = new Map() // `${room}\n${account}` -> { same, at }
119
+ this.hello = null // the promise of this start's hello
120
+ }
121
+
122
+ async call (method, p, body) {
123
+ const res = await this.fetch(`${this.api}${p}`, {
124
+ method,
125
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${this.secret}` },
126
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
127
+ signal: AbortSignal.timeout(API_TIMEOUT_MS)
128
+ })
129
+ if (!res.ok) throw new Error(`the accounts API answered ${res.status}`)
130
+ return res.json()
131
+ }
132
+
133
+ onDisk (room) {
134
+ if (!this.dataDir) return false
135
+ return fs.existsSync(path.join(this.dataDir, `${room}.json`)) || fs.existsSync(path.join(this.dataDir, `${room}.ydoc`))
136
+ }
137
+
138
+ /** Says hello (tried again every 30 s until the API answers): the rooms on this disk; returns those placed elsewhere. */
139
+ start ({ onDisplaced = () => {}, retryMs = 30 * 1000 } = {}) {
140
+ let timer = null
141
+ const once = async () => {
142
+ const rooms = storedRooms(this.dataDir)
143
+ try {
144
+ const r = await this.call('POST', '/v1/relay/machines/hello', { machine: this.machineId, region: this.cc.region, capacity: this.cc.capacity, rooms })
145
+ const displaced = new Set(Array.isArray(r.displaced) ? r.displaced : [])
146
+ for (const room of rooms) if (!displaced.has(room)) this.here.add(room)
147
+ if (displaced.size) onDisplaced([...displaced])
148
+ this.log(`cluster: machine ${this.machineId} holds ${rooms.length - displaced.size} stored room(s)`)
149
+ return true
150
+ } catch (err) {
151
+ this.log(`cluster: could not say hello to the accounts API (${err.message}); trying again in ${Math.round(retryMs / 1000)} s`)
152
+ await new Promise((resolve) => { timer = setTimeout(resolve, retryMs); timer.unref?.() })
153
+ return this.closed ? false : once()
154
+ }
155
+ }
156
+ this.hello = once()
157
+ this.stopHello = () => clearTimeout(timer)
158
+ return this.hello
159
+ }
160
+
161
+ stop () { this.closed = true; if (this.stopHello) this.stopHello() }
162
+
163
+ /** This machine's minute, for the API's placement and heavy rooms. */
164
+ async report (load, rooms) {
165
+ return this.call('POST', '/v1/relay/machines/report', { machine: this.machineId, load, rooms })
166
+ }
167
+
168
+ /** Whether this machine holds `room` (and may load it). */
169
+ holds (room) { return this.here.has(room) && !this.moving.has(room) }
170
+
171
+ /**
172
+ * Where `room` lives: { here } | { machine } | { moving } | { none } | { error }. `fresh` skips
173
+ * the cache of rooms placed elsewhere (a request replayed here that this machine doesn't hold);
174
+ * without `create` (nobody signed in asked) a room placed nowhere is { none } and stays unplaced.
175
+ */
176
+ async locate (room, { fresh = false, create = true } = {}) {
177
+ if (this.moving.has(room)) return { moving: true }
178
+ if (this.here.has(room)) return { here: true }
179
+ const c = this.elsewhere.get(room)
180
+ if (!fresh && c && this.now() - c.at < this.cc.placementTtlMs) return { machine: c.machine }
181
+ const have = this.onDisk(room)
182
+ let r
183
+ try {
184
+ r = await this.call('POST', '/v1/relay/placement', { room, machine: this.machineId, have, create })
185
+ } catch (err) {
186
+ // The API is down: what is on this disk is this machine's (moves delete the old copy); nothing else is.
187
+ if (have) { this.log(`cluster: placement unavailable (${err.message}); serving ${room} from this disk`); this.here.add(room); return { here: true } }
188
+ this.log(`cluster: placement unavailable for ${room} (${err.message})`)
189
+ return { error: NO_PLACEMENT }
190
+ }
191
+ if (r.state === 'moving') return { moving: true }
192
+ if (r.machine === null && r.state === 'none') return { none: true }
193
+ if (r.machine === this.machineId) { this.here.add(room); this.elsewhere.delete(room); return { here: true } }
194
+ if (!MACHINE_RE.test(String(r.machine || ''))) return { error: NO_PLACEMENT }
195
+ this.elsewhere.set(room, { machine: r.machine, at: this.now() })
196
+ if (this.elsewhere.size > 10000) this.elsewhere.delete(this.elsewhere.keys().next().value)
197
+ return { machine: r.machine }
198
+ }
199
+
200
+ /** The room is leaving this machine: refused from now on. */
201
+ release (room) { this.moving.add(room); this.here.delete(room) }
202
+ /** The move failed: the room is this machine's again. */
203
+ restore (room) { this.moving.delete(room); this.here.add(room) }
204
+ /** The room lives on `to` now (or nowhere: deleted). */
205
+ forget (room, to = '') {
206
+ this.moving.delete(room)
207
+ this.here.delete(room)
208
+ if (to && MACHINE_RE.test(to)) this.elsewhere.set(room, { machine: to, at: this.now() }); else this.elsewhere.delete(room)
209
+ }
210
+
211
+ /** A room deleted for good (its owner ended it and it was swept, or idle past its days): no longer placed. */
212
+ forgetPlacement (room) {
213
+ this.forget(room)
214
+ this.call('POST', '/v1/relay/placement/forget', { room, machine: this.machineId }).catch((err) => this.log(`cluster: could not forget ${room}'s placement: ${err.message}`))
215
+ }
216
+
217
+ /** How to reach machine `id`: { url, headers }. */
218
+ peer (id) {
219
+ const t = this.cc.peerUrl.replace(/\/+$/, '')
220
+ return t.includes('{id}') ? { url: t.replaceAll('{id}', id), headers: {} } : { url: t, headers: { 'fly-force-instance-id': id } }
221
+ }
222
+
223
+ /** Streams a request on to machine `id` (`body`: already read, else the request's own stream), and its answer back. */
224
+ forward (req, res, id, body = null) {
225
+ const p = this.peer(id)
226
+ const u = new URL(req.url, p.url)
227
+ const lib = u.protocol === 'https:' ? https : http
228
+ const headers = { ...req.headers, ...p.headers, 'x-quilt-forwarded': this.machineId, host: u.host }
229
+ if (body) { delete headers['transfer-encoding']; headers['content-length'] = String(body.length) }
230
+ const out = lib.request(u, { method: req.method, headers }, (back) => {
231
+ res.writeHead(back.statusCode || 502, back.headers)
232
+ back.pipe(res)
233
+ })
234
+ out.on('error', (err) => {
235
+ this.log(`cluster: could not reach machine ${id}: ${err.message}`)
236
+ if (!res.headersSent) { res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8', 'retry-after': '5' }); res.end(ELSEWHERE) } else res.destroy()
237
+ })
238
+ if (body) out.end(body); else req.pipe(out)
239
+ }
240
+
241
+ /** Another machine's own data (an export), with the relay secret. */
242
+ fromPeer (id, p) {
243
+ const peer = this.peer(id)
244
+ const u = new URL(p, peer.url)
245
+ const lib = u.protocol === 'https:' ? https : http
246
+ return new Promise((resolve, reject) => {
247
+ const r = lib.request(u, { method: 'GET', headers: { ...peer.headers, authorization: `Bearer ${this.secret}` } }, (res) => {
248
+ if (res.statusCode !== 200) { res.resume(); return reject(new Error(`machine ${id} answered ${res.statusCode}`)) }
249
+ resolve(res)
250
+ })
251
+ r.on('error', reject)
252
+ r.end()
253
+ })
254
+ }
255
+
256
+ // ---- hosted agents across machines: the account's sessions and webhook, kept by the API ----
257
+
258
+ /** { rooms, webhook } for `account` from the API (cached), or null when it can't be asked. */
259
+ async hostedOf (account, { fresh = false } = {}) {
260
+ const c = this.hosted.get(account)
261
+ if (!fresh && c && this.now() - c.at < INDEX_TTL_MS) return c
262
+ try {
263
+ const r = await this.call('GET', `/v1/relay/hosted/${encodeURIComponent(account)}`)
264
+ const v = { rooms: Array.isArray(r.rooms) ? r.rooms.filter((x) => ROOM_RE.test(x)) : [], webhook: r.webhook || null, at: this.now() }
265
+ this.hosted.set(account, v)
266
+ if (this.hosted.size > 10000) this.hosted.delete(this.hosted.keys().next().value)
267
+ return v
268
+ } catch (err) {
269
+ this.log(`cluster: could not read ${account}'s sessions (${err.message})`)
270
+ return c || null
271
+ }
272
+ }
273
+
274
+ /** The account joined (`add`) or left (`remove`) a session on this machine. */
275
+ async hostedRoom (account, { add, remove }) {
276
+ const c = this.hosted.get(account)
277
+ if (c) {
278
+ if (add && !c.rooms.includes(add)) c.rooms.push(add)
279
+ if (remove) c.rooms = c.rooms.filter((r) => r !== remove)
280
+ }
281
+ try {
282
+ const r = await this.call('POST', `/v1/relay/hosted/${encodeURIComponent(account)}/rooms`, { ...(add ? { add } : {}), ...(remove ? { remove } : {}) })
283
+ if (c && Array.isArray(r.rooms)) c.rooms = r.rooms
284
+ } catch (err) { this.log(`cluster: could not note ${account}'s sessions (${err.message})`) }
285
+ }
286
+
287
+ async hostedWebhook (account, webhook) {
288
+ const c = this.hosted.get(account)
289
+ if (c) c.webhook = webhook || { off: true }
290
+ try { await this.call('PUT', `/v1/relay/hosted/${encodeURIComponent(account)}/webhook`, { webhook }) } catch (err) { this.log(`cluster: could not keep ${account}'s webhook (${err.message})`) }
291
+ }
292
+
293
+ /**
294
+ * Whether `account` owns session `room` or has been in it, from the API (cached): true/false,
295
+ * or undefined while it is being asked (then `then` runs with the answer).
296
+ */
297
+ sameOwner (room, account, then = () => {}) {
298
+ const k = `${room}\n${account}`
299
+ const c = this.owners.get(k)
300
+ if (c && this.now() - c.at < SAME_OWNER_TTL_MS) return c.same
301
+ if (!c || !c.asking) {
302
+ const entry = { same: c ? c.same : undefined, at: c ? c.at : 0, asking: true }
303
+ this.owners.set(k, entry)
304
+ this.call('POST', '/v1/relay/same-owner', { room, account })
305
+ .then((r) => { const same = r.same === true; const changed = same !== entry.same; Object.assign(entry, { same, at: this.now(), asking: false }); if (changed) then(same) })
306
+ .catch((err) => { entry.asking = false; this.log(`cluster: could not ask who is in ${room} (${err.message})`) })
307
+ if (this.owners.size > 10000) this.owners.delete(this.owners.keys().next().value)
308
+ }
309
+ return c ? c.same : undefined
310
+ }
311
+ }
312
+
313
+ // ---- a room's files, from one machine to another ----
314
+ //
315
+ // The stream is frames: a JSON line ({ path, size }) and that many bytes; the last line is
316
+ // { end: true, files, bytes }. Paths are relative to the data folder and belong to the room.
317
+
318
+ /** The paths of the room's files, relative to the data folder. */
319
+ export function roomPaths (dataDir, room) {
320
+ const out = []
321
+ const walk = (rel) => {
322
+ const abs = path.join(dataDir, rel)
323
+ let st
324
+ try { st = fs.statSync(abs) } catch { return }
325
+ if (st.isDirectory()) { for (const f of fs.readdirSync(abs).sort()) walk(path.posix.join(rel, f)) } else if (st.isFile()) out.push(rel)
326
+ }
327
+ for (const rel of [`${room}.ydoc`, `${room}.json`, `${room}.chat.jsonl`, `branches/${room}`, `files/${room}`, `blobs/${room}`]) walk(rel)
328
+ return out
329
+ }
330
+
331
+ /** Whether `rel` is one of `room`'s own paths (nothing else is written on import). */
332
+ export function roomOwns (room, rel) {
333
+ if (typeof rel !== 'string' || rel.includes('\\') || rel.split('/').some((p) => p === '..' || p === '.' || p === '')) return false
334
+ return rel === `${room}.ydoc` || rel === `${room}.json` || rel === `${room}.chat.jsonl` || [`branches/${room}/`, `files/${room}/`, `blobs/${room}/`].some((p) => rel.startsWith(p))
335
+ }
336
+
337
+ /** Writes room `room`'s files (and `extras`, JSON) to `res` as frames, a file at a time from disk. */
338
+ export async function packRoom (dataDir, room, extras, res) {
339
+ let files = 0
340
+ let bytes = 0
341
+ const write = (chunk) => new Promise((resolve, reject) => {
342
+ const fail = (err) => reject(err)
343
+ res.once('error', fail)
344
+ const done = () => { res.off('error', fail); resolve() }
345
+ if (res.write(chunk)) done(); else res.once('drain', done)
346
+ })
347
+ const x = Buffer.from(JSON.stringify(extras || {}))
348
+ await write(`${JSON.stringify({ path: ':extras', size: x.length })}\n`)
349
+ await write(x)
350
+ for (const rel of roomPaths(dataDir, room)) {
351
+ const abs = path.join(dataDir, rel)
352
+ const size = fs.statSync(abs).size
353
+ await write(`${JSON.stringify({ path: rel, size })}\n`)
354
+ let sent = 0
355
+ for await (const chunk of fs.createReadStream(abs, { end: Math.max(0, size - 1), highWaterMark: 1024 * 1024 })) {
356
+ if (!size) break
357
+ await write(chunk)
358
+ sent += chunk.length
359
+ }
360
+ if (sent !== size) throw new Error(`${rel} changed while it was being copied`)
361
+ files++
362
+ bytes += size
363
+ }
364
+ res.end(`${JSON.stringify({ end: true, files, bytes })}\n`)
365
+ return { files, bytes }
366
+ }
367
+
368
+ /**
369
+ * Reads frames from `stream` into `stage` (a fresh folder), checking each path belongs to `room`,
370
+ * writing each file as its bytes come. Resolves { files, bytes, extras } once the end line matches
371
+ * what came; rejects otherwise.
372
+ */
373
+ export async function unpackRoom (stream, room, stage) {
374
+ fs.rmSync(stage, { recursive: true, force: true })
375
+ fs.mkdirSync(stage, { recursive: true })
376
+ let head = Buffer.alloc(0) // the start of a frame's line, until its newline comes
377
+ let want = null // { path, size, left, fd?, extras? }
378
+ let files = 0
379
+ let bytes = 0
380
+ let extras = {}
381
+ let end = null
382
+ const finish = () => {
383
+ if (want.path === ':extras') extras = JSON.parse(Buffer.concat(want.parts).toString('utf8') || '{}')
384
+ else { fs.closeSync(want.fd); files++; bytes += want.size }
385
+ want = null
386
+ }
387
+ try {
388
+ for await (const chunk of stream) {
389
+ let buf = chunk
390
+ while (buf.length && !end) {
391
+ if (!want) {
392
+ const nl = buf.indexOf(10)
393
+ if (nl < 0) { head = Buffer.concat([head, buf]); if (head.length > 4096) throw new Error('a malformed frame'); buf = buf.subarray(buf.length); break }
394
+ const line = JSON.parse(Buffer.concat([head, buf.subarray(0, nl)]).toString('utf8'))
395
+ head = Buffer.alloc(0)
396
+ buf = buf.subarray(nl + 1)
397
+ if (line.end) { end = line; break }
398
+ if (!(Number.isInteger(line.size) && line.size >= 0)) throw new Error('a malformed frame')
399
+ if (line.path === ':extras') {
400
+ if (line.size > 16 * 1024 * 1024) throw new Error('a malformed frame')
401
+ want = { path: line.path, size: line.size, left: line.size, parts: [] }
402
+ } else {
403
+ if (!roomOwns(room, line.path)) throw new Error(`a file that isn't the room's: ${String(line.path).slice(0, 100)}`)
404
+ const abs = path.join(stage, line.path)
405
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
406
+ want = { path: line.path, size: line.size, left: line.size, fd: fs.openSync(abs, 'w') }
407
+ }
408
+ if (!want.left) finish()
409
+ continue
410
+ }
411
+ const part = buf.subarray(0, want.left)
412
+ buf = buf.subarray(part.length)
413
+ if (want.parts) want.parts.push(part); else fs.writeSync(want.fd, part)
414
+ want.left -= part.length
415
+ if (!want.left) finish()
416
+ }
417
+ if (end) break
418
+ }
419
+ } finally {
420
+ if (want && want.fd !== undefined) fs.closeSync(want.fd)
421
+ }
422
+ if (!end || want || end.files !== files || end.bytes !== bytes) throw new Error(`the copy was cut short (${files} of ${end ? end.files : '?'} files)`)
423
+ return { files, bytes, extras }
424
+ }
425
+
426
+ /** Moves a staged room into the data folder, replacing whatever copy of it was there. */
427
+ export function placeStaged (dataDir, room, stage) {
428
+ for (const rel of [`${room}.ydoc`, `${room}.json`, `${room}.chat.jsonl`, `branches/${room}`, `files/${room}`, `blobs/${room}`]) {
429
+ const from = path.join(stage, rel)
430
+ const to = path.join(dataDir, rel)
431
+ fs.rmSync(to, { recursive: true, force: true })
432
+ if (!fs.existsSync(from)) continue
433
+ fs.mkdirSync(path.dirname(to), { recursive: true })
434
+ fs.renameSync(from, to)
435
+ }
436
+ fs.rmSync(stage, { recursive: true, force: true })
437
+ }
438
+
439
+ /** Deletes this machine's copy of a room that lives elsewhere now (never its stored large files elsewhere). */
440
+ export function removeLocalRoom (dataDir, room) {
441
+ for (const rel of [`${room}.ydoc`, `${room}.json`, `${room}.chat.jsonl`, `branches/${room}`, `files/${room}`, `blobs/${room}`]) fs.rmSync(path.join(dataDir, rel), { recursive: true, force: true })
442
+ }
443
+
444
+ /** Moves a room this machine should not hold aside, to `displaced/` (never served, never deleted). */
445
+ export function displaceRoom (dataDir, room, at = Date.now()) {
446
+ const dest = path.join(dataDir, 'displaced', `${room}-${at}`)
447
+ for (const rel of [`${room}.ydoc`, `${room}.json`, `${room}.chat.jsonl`, `branches/${room}`, `files/${room}`]) {
448
+ const from = path.join(dataDir, rel)
449
+ if (!fs.existsSync(from)) continue
450
+ fs.mkdirSync(path.dirname(path.join(dest, rel)), { recursive: true })
451
+ fs.renameSync(from, path.join(dest, rel))
452
+ }
453
+ return dest
454
+ }