heyquilt 0.3.138 → 0.3.141

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 (53) hide show
  1. package/RELEASES.md +10 -0
  2. package/bin/quilt.js +7 -143
  3. package/package.json +1 -1
  4. package/src/account.js +0 -29
  5. package/src/api/memory-store.js +0 -29
  6. package/src/api/routes/teams.js +0 -23
  7. package/src/api/server.js +5 -19
  8. package/src/api/supabase-store.js +0 -52
  9. package/src/branch-view.js +6 -131
  10. package/src/control.js +2 -33
  11. package/src/duties.js +1 -1
  12. package/src/github-commit.js +3 -315
  13. package/src/mcp.js +8 -157
  14. package/src/relay-ask.js +3 -66
  15. package/src/relay-cluster.js +3 -5
  16. package/src/relay-mcp.js +6 -418
  17. package/src/relay-prune.js +6 -145
  18. package/src/relay-turn-sync.js +1 -1
  19. package/src/server.js +33 -1331
  20. package/src/session-follow.js +18 -5
  21. package/src/session.js +58 -3526
  22. package/src/status.js +2 -58
  23. package/src/turn-sync.js +15 -2
  24. package/src/ui/changes.js +5 -14
  25. package/src/ui/chat.js +0 -1
  26. package/src/ui/common.js +0 -2
  27. package/src/ui/git-rule.js +2 -30
  28. package/src/ui/home.js +3 -76
  29. package/src/ui/session.js +34 -528
  30. package/src/ui/tree.js +2 -8
  31. package/src/ui/workspaces.js +2 -122
  32. package/src/ui-server.js +1 -38
  33. package/src/api/routes/github.js +0 -380
  34. package/src/branches.js +0 -194
  35. package/src/clash.js +0 -128
  36. package/src/commit-request.js +0 -59
  37. package/src/commit.js +0 -213
  38. package/src/git-hook.js +0 -48
  39. package/src/gitignore.js +0 -49
  40. package/src/gitstate.js +0 -1069
  41. package/src/relay-ci.js +0 -111
  42. package/src/relay-clash.js +0 -214
  43. package/src/relay-commit.js +0 -923
  44. package/src/relay-filekey.js +0 -46
  45. package/src/relay-git.js +0 -274
  46. package/src/relay-githttp.js +0 -166
  47. package/src/relay-github.js +0 -109
  48. package/src/relay-outbound.js +0 -1295
  49. package/src/relay-repo-moves.js +0 -43
  50. package/src/relay-sync.js +0 -795
  51. package/src/relay-upstream.js +0 -889
  52. package/src/sync-words.js +0 -42
  53. package/src/upstream.js +0 -192
@@ -1,889 +0,0 @@
1
- // Commits pushed to GitHub reach a session through a member's folder (Session.checkUpstream).
2
- // When no folder that can bring them in is online on a branch (none with git, or only ones
3
- // that can't: held, diverged, a viewer's, or whose fetch fails) and members still work there
4
- // (hosted agents over HTTP, chat links, CLI agents or apps without git), nothing would bring
5
- // them in, and they would work on stale files.
6
- // So the relay does it for that branch: it learns from members' presence which repository
7
- // and upstream branch the branch follows, and the last commit a folder on it was at while
8
- // in step with the session; then, every so often, it asks GitHub whether that upstream
9
- // moved on, and if it is strictly ahead, merges its files into the branch document the
10
- // same way a folder would (upstream.js planCatchUp): where both changed the same lines, Quilt
11
- // combines them itself, the newer edit keeping them, and tells whoever's lines lost
12
- // (relay-clash.js). Nobody is asked to merge. Never a history merge, never anything written to GitHub.
13
- //
14
- // The relay has no git: the commit it records is "the session's files are this commit plus
15
- // the session's work". A folder that comes back behind its upstream follows it like a
16
- // member already at that commit (Session.memberAt), moving git without writing files.
17
- import { DEFAULT_KEY } from './branchdocs.js'
18
- import { appCredentials } from './relay-github.js'
19
- import * as Y from 'yjs'
20
- import { planCatchUp, publishedOf } from './upstream.js'
21
- import { applyTextDiff } from './textdiff.js'
22
- import { isDiff } from './branch-view.js'
23
- import { looksBinary, sha1 } from './fsutil.js'
24
- import { LARGE_FILE_BYTES, MAX_TEXT_BYTES, makeIgnore, scopeIgnore, isIgnored, isSafeRelPath } from './pathrules.js'
25
- import { unionMatcher } from './merge3.js'
26
- import { readTasks, updateTask } from './tasks.js'
27
- import { HistoryLog } from './history.js'
28
- import { RELAY_BY, isRelay } from './branches.js'
29
-
30
- /** How often the relay asks GitHub about a branch nobody's folder is on (per branch). */
31
- export const UPSTREAM_CHECK_MS = 10 * 60 * 1000
32
- /** A branch counts as worked on by hosted agents this long after one of their calls on it. */
33
- export const HOSTED_RECENT_MS = 30 * 60 * 1000
34
- /** The longest the relay waits after GitHub says to slow down, and the first wait without a hint. */
35
- export const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000
36
- export const FIRST_BACKOFF_MS = 15 * 60 * 1000
37
- /** GitHub's compare lists at most this many files: more and the relay leaves it to a folder. */
38
- const COMPARE_FILES = 300
39
- /** Kept for server.js's settings (an older clash task's assignee was handed on after this long); unused since Quilt merges on its own. */
40
- export const CLASH_IDLE_MS = 15 * 60 * 1000
41
- /** quilt_sync_branch asks GitHub at most this often per branch (Room.syncUpstreamNow). */
42
- export const SYNC_EVERY_MS = 60 * 1000
43
- /** The same through the relay's git mirror (GitHub as a node), where a look costs no API quota. */
44
- export const SYNC_EVERY_GIT_MS = 5 * 1000
45
- /** The most files the relay loads to start a branch from GitHub (loadBranch). */
46
- export const LOAD_FILES = 1000
47
- const API = 'https://api.github.com'
48
- const RAW = 'https://raw.githubusercontent.com'
49
- const SHA = /^[0-9a-f]{40,64}$/
50
- export const RELAY_ORIGIN = 'relay-upstream' // transaction origin: not a connection, so never guarded or undone
51
- const ORIGIN = RELAY_ORIGIN
52
- // GitHub's tokens: classic and fine-grained (ghp_, github_pat_), app installation (ghs_), and the newer
53
- // longer ones (an Actions run's GITHUB_TOKEN is a JWT: dots and dashes too). Never spaces or line breaks.
54
- const TOKEN = /^[A-Za-z0-9_.-]{20,2048}$/
55
-
56
- /** Whether `t` looks like a GitHub token (classic ghp_…, fine-grained github_pat_…, or the older bare kind). */
57
- export const validToken = (t) => typeof t === 'string' && TOKEN.test(t)
58
-
59
- /**
60
- * A git remote URL as host/owner/name, without any user name or password it carried:
61
- * { host, owner, name, github, slug } or null. https, ssh:// and scp-like git@host:owner/name.
62
- */
63
- export function parseRepo (url) {
64
- if (typeof url !== 'string' || !url || url.length > 500) return null
65
- let host, rest
66
- const scp = /^[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):(?!\/)(.+)$/.exec(url)
67
- if (scp) { host = scp[1]; rest = scp[2] } else {
68
- let u
69
- try { u = new URL(url) } catch { return null }
70
- if (!['https:', 'http:', 'ssh:', 'git:'].includes(u.protocol)) return null
71
- host = u.hostname
72
- rest = u.pathname.replace(/^\/+/, '')
73
- }
74
- const parts = rest.replace(/\/+$/, '').replace(/\.git$/, '').split('/')
75
- if (parts.length !== 2 || !parts.every((p) => /^[A-Za-z0-9._-]+$/.test(p) && p !== '.' && p !== '..')) return host ? { host: host.toLowerCase(), owner: null, name: null, github: false, slug: host.toLowerCase() } : null
76
- host = host.toLowerCase()
77
- const github = host === 'github.com' || host === 'www.github.com'
78
- return { host: github ? 'github.com' : host, owner: parts[0], name: parts[1], github, slug: `${github ? 'github.com' : host}/${parts[0]}/${parts[1]}` }
79
- }
80
-
81
- /** The branch on the remote an upstream like "origin/main" names ("main"), given the remote's name when known. */
82
- export function upstreamRef (name, remote = null) {
83
- if (typeof name !== 'string' || !name.includes('/')) return null
84
- const ref = remote && name.startsWith(`${remote}/`) ? name.slice(remote.length + 1) : name.slice(name.indexOf('/') + 1)
85
- return ref && !ref.startsWith('/') && !ref.includes('..') && ref.length <= 200 ? ref : null
86
- }
87
-
88
- const plural = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
89
-
90
- /** The room's record of each branch's upstream, as the relay keeps it (meta.upstreams, key -> record). */
91
- export function upstreamsOf (room) {
92
- if (!room.meta.upstreams || typeof room.meta.upstreams !== 'object') room.meta.upstreams = {}
93
- return room.meta.upstreams
94
- }
95
-
96
- /** Whether a member (its access on connection `ws`) may change every file: an approved editor with no folder limits, owner included. */
97
- export function editsAll (room, ws) {
98
- if (!room.access || !room.access.get) return true // a stand-in room with no access (unit tests)
99
- const a = room.access.get(ws)
100
- return !!a && a.role !== 'viewer' && !(Array.isArray(a.scopes) && a.scopes.length) && !(Array.isArray(a.scopesExcept) && a.scopesExcept.length)
101
- }
102
-
103
- /**
104
- * What members' presence says about each branch's upstream, recorded per branch document:
105
- * the repository, the upstream branch, and the commit the session's files are (that commit plus
106
- * the session's work). Only a member who may change every file is trusted with it (a viewer or a
107
- * member limited to some folders could point the relay at another repository, or at a commit
108
- * whose files they were refused). Taken only from a folder that isn't behind, diverged, held or
109
- * clashing; one that is only ahead (commits not pushed yet) gives its upstream's tip, which
110
- * GitHub has. A newer commit replaces the recorded one only when that folder's git says it
111
- * descends from it (upstream.past); an older app, which doesn't say, never moves it backwards
112
- * past what the relay brought in. Returns whether anything changed (the caller saves and tells everyone).
113
- */
114
- export function noteFolders (room, log = () => {}) {
115
- const recs = upstreamsOf(room)
116
- const states = room.awareness.getStates()
117
- let changed = false
118
- const told = new Set() // branches a folder just said the repository of
119
- for (const [ws, ids] of room.conns) {
120
- const key = ws.branch
121
- if (!key || !room.meta.branches[key] || !editsAll(room, ws)) continue
122
- for (const id of ids) {
123
- const g = states.get(id)?.git
124
- if (!g || typeof g !== 'object' || typeof g.branch !== 'string') continue
125
- // Mid-switch, a folder's git can be on another branch than the document its connection is on:
126
- // its word then is about that other branch, never this record's.
127
- if (key !== DEFAULT_KEY && typeof g.key === 'string' && g.key !== key && !(room.resolveKey && room.resolveKey(g.key) === room.resolveKey(key))) continue
128
- const u = g.upstream && typeof g.upstream === 'object' ? g.upstream : null
129
- const rec = recs[key] || {}
130
- const next = { ...rec }
131
- // A folder whose origin is this relay's git (relay-githttp.js, /git/<room>.git) says nothing about which repository the session follows.
132
- const viaRelay = u && typeof u.url === 'string' && new RegExp(`/git/${room.name.replace(/[^A-Za-z0-9_-]/g, '')}\\.git/?$`).test(u.url)
133
- if (u && typeof u.name === 'string' && !viaRelay) {
134
- const repo = parseRepo(u.url)
135
- const ref = upstreamRef(u.name, typeof u.remote === 'string' ? u.remote : null)
136
- if (repo && ref) {
137
- // A folder whose origin still names a repository's old name (renamed or moved on GitHub: relay-repo-moves.js) means the new one.
138
- const slug = room.repoMoves ? room.repoMoves.resolve(repo.slug) : repo.slug
139
- if (rec.repo && rec.repo !== slug) {
140
- log(`[${room.name}] ${key}: the relay follows ${slug} now (was ${rec.repo}), as ${(room.names && room.names.get(ws)) || 'a member'}'s folder says`)
141
- delete next.github // another repository: the GitHub connection is asked about again at once (noteConnections)
142
- }
143
- if (slug !== repo.slug) next.was = [...new Set([...(Array.isArray(next.was) ? next.was : []), repo.slug])].slice(-5)
144
- next.repo = slug; next.name = u.name.slice(0, 200); next.ref = ref
145
- told.add(key)
146
- }
147
- }
148
- const head = typeof g.sha === 'string' && SHA.test(g.sha) ? g.sha : null
149
- const tip = u && typeof u.sha === 'string' && SHA.test(u.sha) ? u.sha : null
150
- // A newer app says which head its upstream's facts were found for (at): one that moved since is looked at again first.
151
- const inStep = head && !g.held && u && !u.diverged && !(u.behind > 0) && !(u.conflicts > 0) && u.fetchOk !== false && (typeof u.at !== 'string' || u.at === head)
152
- // The commit the session's files are at. A folder's head when it is on the upstream (a newer app
153
- // says so: headPushed), else the upstream's tip, which GitHub has: a local commit not pushed yet
154
- // (or dropped later) recorded here would be one nobody can descend from, and the record would never move again.
155
- const local = !!u && (u.headPushed === false || u.ahead > 0)
156
- const sha = !inStep ? null : local && tip ? tip : head
157
- if (sha && sha !== rec.sha) {
158
- const tells = 'past' in u // a newer app says whether its upstream descends from the recorded commit
159
- // A fetched folder that doesn't have the recorded commit at all: the upstream never had it (a local
160
- // commit of some folder, dropped since). Nobody can descend from it, so this folder's word stands.
161
- // A commit the relay knows is GitHub's (it pushed it, brought it in, or measures the session from it) is GitHub's:
162
- // a folder that hasn't fetched it yet says nothing about it.
163
- const st = room.meta.sync && room.meta.sync[key]
164
- const githubs = (rec.relayHead && rec.relayHead === rec.sha) || (st && (st.base === rec.sha || st.pushed === rec.sha))
165
- const dead = tells && u.relayKnown === false && u.fetchOk !== false && !!tip && !githubs
166
- const descends = !rec.sha || dead || (tells ? u.past === rec.sha : !rec.relayHead || sha === rec.relayHead || (tip && tip === rec.head))
167
- if (dead) log(`[${room.name}] ${key}: ${String(rec.sha).slice(0, 7)} isn't on ${u.name}, as ${(room.names && room.names.get(ws)) || 'a member'}'s folder says; following its ${sha.slice(0, 7)}`)
168
- if (descends) {
169
- next.sha = sha
170
- // The commit of GitHub's the folder is on, from the same report: what the session is measured from if `sha` turns out to be the folder's own (relay-sync.js).
171
- if (tip) next.tip = tip; else delete next.tip
172
- delete next.relayHead // folders are at or past what the relay brought in
173
- if (rec.clash && rec.clash.sha === sha) delete next.clash // a folder landed that clash's merge
174
- }
175
- } else if (sha && sha === rec.relayHead) delete next.relayHead
176
- if (JSON.stringify(next) !== JSON.stringify(rec)) { recs[key] = next; changed = true }
177
- }
178
- }
179
- if (followMove(room, told, log)) changed = true
180
- return changed
181
- }
182
-
183
- /**
184
- * A repository moved to another owner on GitHub (transferred): the default branch's folders say its
185
- * new name, and the session's other branches of it, with no folder on them to say, follow too
186
- * (an agent's old branch, say), their GitHub connection asked again. A folder's own word stands.
187
- */
188
- function followMove (room, told, log) {
189
- const recs = upstreamsOf(room)
190
- const main = (room.defaultKey && recs[room.defaultKey]) || recs[DEFAULT_KEY]
191
- const to = main && main.repo
192
- if (!to) return false
193
- const [host, owner, name] = to.split('/')
194
- let changed = false
195
- for (const [key, rec] of Object.entries(recs)) {
196
- if (!rec || !rec.repo || rec.repo === to || told.has(key)) continue
197
- const [h, o, n] = rec.repo.split('/')
198
- if (h !== host || !n || n.toLowerCase() !== String(name || '').toLowerCase() || o.toLowerCase() === String(owner || '').toLowerCase()) continue
199
- log(`[${room.name}] ${key}: the relay follows ${to} now (was ${rec.repo}), as the default branch does`)
200
- const next = { ...rec, repo: to }
201
- delete next.github
202
- recs[key] = next
203
- changed = true
204
- }
205
- return changed
206
- }
207
-
208
- /** A hold this long (busy or settling, not switching branches) no longer keeps the relay out of a branch. */
209
- export const LONG_HOLD_MS = 30 * 60 * 1000
210
-
211
- /** Since when the relay has seen each connected folder held (kind and time, its own clock), kept up from presence. */
212
- export function noteHolds (room, now = Date.now()) {
213
- if (!room.heldSince) room.heldSince = new WeakMap()
214
- const states = room.awareness.getStates()
215
- for (const [ws, ids] of room.conns) {
216
- let held = null
217
- for (const id of ids) { const g = states.get(id)?.git; if (g && typeof g === 'object' && g.held) held = String(g.held) }
218
- const was = room.heldSince.get(ws)
219
- if (!held) room.heldSince.delete(ws)
220
- else if (!was || was.kind !== held) room.heldSince.set(ws, { kind: held, since: now })
221
- }
222
- }
223
-
224
- /**
225
- * Whether a member's folder (its presence `g` on connection `ws`) keeps the relay out of its
226
- * branch: git on a branch with an upstream, its last fetch worked, and its member may change
227
- * files. A folder that is held (git at work: a pull, a merge being resolved, a branch switch) or
228
- * diverged still does: its own git is under way, and the relay writing into the branch would
229
- * collide with it. Only a hold the relay has seen for over 30 minutes (never a branch switch) lets it in.
230
- */
231
- export function canBringIn (room, ws, g, now = Date.now()) {
232
- if (!g || typeof g !== 'object' || typeof g.branch !== 'string' || !g.branch) return false
233
- const u = g.upstream && typeof g.upstream === 'object' ? g.upstream : null
234
- if (!u || typeof u.name !== 'string' || !u.name || u.fetchOk === false) return false
235
- const a = room.access && room.access.get ? room.access.get(ws) : null
236
- if (a && a.role === 'viewer') return false
237
- if (g.held && g.held !== 'switching') {
238
- const h = room.heldSince && room.heldSince.get(ws)
239
- if (h && now - h.since > LONG_HOLD_MS) return false
240
- }
241
- return true
242
- }
243
-
244
- /** Whether `name` (a member, or one of its AI sessions) is connected on branch `key`. */
245
- export function connectedAs (room, key, name) {
246
- if (!name) return false
247
- const states = room.awareness.getStates()
248
- for (const [ws, ids] of room.conns) {
249
- if (ws.branch !== key) continue
250
- if (room.names && room.names.get(ws) === name) return true
251
- for (const id of ids) for (const p of (states.get(id)?.personas || [])) if (p && p.name === name) return true
252
- }
253
- return false
254
- }
255
-
256
- /** The member whose folder on branch `key` brings commits in itself (canBringIn), or null. */
257
- export function folderAble (room, key) {
258
- const states = room.awareness.getStates()
259
- for (const [ws, ids] of room.conns) {
260
- if (ws.branch !== key) continue
261
- for (const id of ids) {
262
- const st = states.get(id)
263
- if (st && canBringIn(room, ws, st.git)) return (room.names && room.names.get(ws)) || st.name || 'a member'
264
- }
265
- }
266
- return null
267
- }
268
-
269
- /** Whether a folder that can bring commits in is connected on branch `key` (then the relay leaves it to that folder). */
270
- export function folderOn (room, key) { return folderAble(room, key) !== null }
271
-
272
- /** What a folder waiting for another member to bring commits in says (session.js CATCHER_WAITING): not stuck. */
273
- const OLD_CATCHER_WAITING = 'another member is bringing these commits in'
274
-
275
- /**
276
- * The member whose folder on branch `key` brings GitHub's commits in itself even where the relay does (an app
277
- * older than 0.3.65, which doesn't say followsRelay), or null. The relay then leaves bringing commits in to it,
278
- * as before: both bringing in the same commit at once would share it twice (the session's document keeps both
279
- * copies of every change).
280
- */
281
- export function catcherAble (room, key) {
282
- const states = room.awareness.getStates()
283
- for (const [ws, ids] of room.conns) {
284
- if (ws.branch !== key) continue
285
- for (const id of ids) {
286
- const st = states.get(id)
287
- // Only one that can bring them in now: a folder diverged from GitHub (its own commits not pushed), stopped by
288
- // a clash, or waiting on something of its own never brings them in, and the session would never get them.
289
- const u = st && st.git && st.git.upstream
290
- const stuck = !u || u.diverged || u.conflicts > 0 || (u.waiting && u.waiting !== OLD_CATCHER_WAITING)
291
- if (st && st.git && st.git.followsRelay !== 1 && !stuck && canBringIn(room, ws, st.git)) return (room.names && room.names.get(ws)) || st.name || 'a member'
292
- }
293
- }
294
- return null
295
- }
296
-
297
- /** Whether any member's app or CLI is connected on branch `key` (with or without git). */
298
- export function connectedOn (room, key) {
299
- for (const ws of room.conns.keys()) if (ws.branch === key) return true
300
- return false
301
- }
302
-
303
- /** Hosted agents on branch `key` right now: [{ id, name, seen, role, scopes, scopesExcept, chat? }], the most recently active first. */
304
- export function hostedOn (room, key) {
305
- return room.hostedOnline()
306
- .filter((h) => room.hostedBranch(h.id) === key)
307
- .map((h) => ({ ...h, ...(room.meta.members[h.id] || {}), name: h.name, seen: h.seen }))
308
- .sort((a, b) => b.seen - a.seen || (a.name < b.name ? -1 : 1))
309
- }
310
-
311
- /**
312
- * Whether the relay should look at branch `key` now: members work there (hosted agents now or in
313
- * the last 30 minutes, chat links, or apps and CLI agents connected on it) and no folder on it
314
- * can bring commits in itself (folderAble).
315
- */
316
- export function relayLooks (room, key, now = Date.now()) {
317
- if (!room.exists || room.ended || !room.meta.branches[key]) return false
318
- const rec = upstreamsOf(room)[key]
319
- if (!rec || !rec.repo) return false
320
- // GitHub as a node (relay-sync.js): the relay is the master node and brings commits in for every
321
- // branch anyone is on; folders follow its commits (session.js memberAt) instead of pulling themselves.
322
- // Except while an older app's folder there brings them in itself (catcherAble): then only files the relay holds
323
- // (for a claim, or a clash) are looked at, as nobody else brings their change in.
324
- const holds = !!(room.meta.sync && room.meta.sync[key] && (room.meta.sync[key].held || []).length)
325
- if (room.cfg && room.cfg.gitSync && room.mirrors) return (holds || !catcherAble(room, key)) && (folderOn(room, key) || connectedOn(room, key) || hostedOn(room, key).length > 0 || now - (rec.hostedAt || 0) < HOSTED_RECENT_MS)
326
- if (folderOn(room, key)) return false
327
- return connectedOn(room, key) || hostedOn(room, key).length > 0 || now - (rec.hostedAt || 0) < HOSTED_RECENT_MS
328
- }
329
-
330
- /** The branches due a look (only `keys` when given): looked at by the relay, last checked `everyMs` ago or more, and not told to wait by GitHub. */
331
- export function dueBranches (room, { now = Date.now(), everyMs = UPSTREAM_CHECK_MS, keys = null } = {}) {
332
- const recs = upstreamsOf(room)
333
- return Object.keys(recs).filter((key) => (!keys || keys.includes(key)) && !recs[key].gone && relayLooks(room, key, now) && now - Math.max(recs[key].checkedAt || 0, recs[key].webhookAt || 0) >= everyMs && !(recs[key].backoffUntil > now))
334
- }
335
-
336
- /**
337
- * GitHub says repository `from` is now `to` (renamed, or moved to another owner): each branch of the
338
- * room that follows it follows `to`, remembers `from` (members' apps point their folders' origin at
339
- * the new name: Session.followRepoMove), and its GitHub connection is asked about again. Returns the
340
- * keys of the branches that moved.
341
- */
342
- export function applyRepoMove (room, from, to) {
343
- const moved = []
344
- for (const [key, rec] of Object.entries(upstreamsOf(room))) {
345
- if (!rec || typeof rec.repo !== 'string' || rec.repo.toLowerCase() !== String(from).toLowerCase()) continue
346
- rec.was = [...new Set([...(Array.isArray(rec.was) ? rec.was : []), rec.repo])].slice(-5)
347
- rec.repo = to
348
- delete rec.github
349
- moved.push(key)
350
- }
351
- return moved
352
- }
353
-
354
- /** What members see of the relay's record in the branch list (never the token, an ETag or a URL with credentials). */
355
- export function relayEntry (rec) {
356
- if (!rec || !rec.sha) return null
357
- return {
358
- sha: rec.sha,
359
- // Whether agents can commit here through a member's GitHub connection (noteConnection): { state, login } or null (not asked yet).
360
- github: rec.github ? { state: rec.github.state, login: rec.github.login || '' } : null,
361
- upstream: rec.name || null,
362
- repo: rec.repo || null,
363
- // The repository's earlier names (it was renamed or moved): a folder whose origin is one of them points it at `repo`.
364
- ...(Array.isArray(rec.was) && rec.was.length ? { was: rec.was } : {}),
365
- checkedAt: rec.checkedAt || 0,
366
- brought: rec.brought ? { count: rec.brought.count || 0, files: rec.brought.files || 0, at: rec.brought.at || 0 } : null,
367
- problem: rec.problem || null,
368
- clashTask: rec.clash ? rec.clash.task : null
369
- }
370
- }
371
-
372
- /**
373
- * Asks whether a member's GitHub connection lets the relay write to the repository branch `key`
374
- * follows (relay-github.js), and keeps the answer on the record for the branch list: 'ok',
375
- * 'not-connected', 'not-installed', 'no-access' (with the login asked), or 'not-github'. `force`
376
- * forgets a remembered "no" first (someone just connected). Resolves whether it changed.
377
- */
378
- export async function noteConnection (room, key, { force = false } = {}) {
379
- const rec = upstreamsOf(room)[key]
380
- if (!rec || !rec.repo) return false
381
- const repo = parseRepo(`https://${rec.repo}`)
382
- const next = { at: Date.now() }
383
- // Each check is numbered: one that started earlier and answers later (the API slow at the room's load) is old news.
384
- if (!room.connectionChecks) room.connectionChecks = new Map()
385
- const n = (room.connectionChecks.get(key) || 0) + 1
386
- room.connectionChecks.set(key, n)
387
- if (!repo || !repo.github) Object.assign(next, { state: 'not-github' })
388
- else {
389
- const full = `${repo.owner}/${repo.name}`
390
- if (force && room.appTokens) for (const k of [...room.appTokens.keys()]) if (k.split('\0')[1] === full.toLowerCase() && !room.appTokens.get(k).token) room.appTokens.delete(k) // keys: access, repo, who it is for
391
- const r = await appCredentials(room, full, { access: 'write', fresh: force })
392
- Object.assign(next, r.token ? { state: 'ok', login: r.login || '' } : { state: r.state === 'off' ? 'not-connected' : r.state, login: r.login || '' })
393
- }
394
- if (room.connectionChecks.get(key) !== n) return false
395
- const was = rec.github
396
- rec.github = next
397
- return !was || was.state !== next.state || (was.login || '') !== (next.login || '')
398
- }
399
-
400
- /** An HTTP failure from GitHub, with what the back-off needs. */
401
- class GitHubError extends Error {
402
- constructor (status, headers, what) {
403
- super(`GitHub answered ${status} for ${what}`)
404
- this.status = status
405
- this.headers = headers
406
- }
407
- }
408
-
409
- // GitHub's API allows 60 requests an hour per IP without a token (5000 with one), shared by every
410
- // session on this relay: one budget for the whole process, kept under the limit with headroom, and
411
- // paused for everyone when GitHub says the limit is nearly spent. raw.githubusercontent.com isn't counted.
412
- const HOUR = 60 * 60 * 1000
413
- const HEADROOM = 10
414
- const budgets = new Map() // 'anon' | sha1(token) -> { used: [ts], pausedUntil }
415
- let anonPerHour = 50
416
- /** Sets how many API requests an hour the relay makes without a token (tests raise it). */
417
- export function configureGithubBudget ({ anonPerHour: n } = {}) { if (Number.isFinite(n) && n > 0) anonPerHour = n }
418
- /** Forgets what was spent (tests). */
419
- export function resetGithubBudgets () { budgets.clear() }
420
- function budgetOf (token) {
421
- const k = token ? sha1(Buffer.from(token, 'utf8')) : 'anon'
422
- if (!budgets.has(k)) budgets.set(k, { used: [], pausedUntil: 0 })
423
- return budgets.get(k)
424
- }
425
- /** Spends one API request, or throws a 429-like GitHubError saying how long to wait. */
426
- function spend (token, now = Date.now()) {
427
- const b = budgetOf(token)
428
- const wait = (until) => new GitHubError(429, new Headers({ 'retry-after': String(Math.max(1, Math.ceil((until - now) / 1000))) }), 'the relay\'s own GitHub budget')
429
- if (b.pausedUntil > now) throw wait(b.pausedUntil)
430
- while (b.used.length && b.used[0] <= now - HOUR) b.used.shift()
431
- if (!token && b.used.length >= anonPerHour) { b.pausedUntil = b.used[0] + HOUR; throw wait(b.pausedUntil) }
432
- b.used.push(now)
433
- }
434
- /** What GitHub said is left: nearly spent pauses every room until its reset. */
435
- function heard (token, headers) {
436
- const rem = Number(headers && headers.get && headers.get('x-ratelimit-remaining'))
437
- const reset = Number(headers && headers.get && headers.get('x-ratelimit-reset'))
438
- if (headers && headers.get && headers.get('x-ratelimit-remaining') !== null && Number.isFinite(rem) && rem <= HEADROOM && Number.isFinite(reset)) budgetOf(token).pausedUntil = reset * 1000
439
- }
440
- /** A 403 or 429 that is GitHub's rate limit (back off), not a refusal to read (no access). */
441
- const rateLimited = (err) => err.status === 429 || (err.status === 403 && !!err.headers && !!err.headers.get && (err.headers.get('x-ratelimit-remaining') === '0' || err.headers.get('retry-after') !== null))
442
-
443
- /** The few GitHub calls the relay makes, read only. The token (when set) goes in a header, never a URL. */
444
- function github ({ fetch, token, owner, name }) {
445
- const auth = token ? { authorization: `Bearer ${token}` } : {}
446
- const base = { 'user-agent': 'quilt-relay', 'x-github-api-version': '2022-11-28', ...auth }
447
- const enc = (p) => p.split('/').map(encodeURIComponent).join('/')
448
- const get = async (url, headers, what) => {
449
- spend(token)
450
- const res = await fetch(url, { headers: { ...base, ...headers }, redirect: 'follow' })
451
- heard(token, res.headers)
452
- if (res.status === 304) return { res, notModified: true }
453
- if (!res.ok) throw new GitHubError(res.status, res.headers, what)
454
- return { res }
455
- }
456
- return {
457
- /** The ref's head commit, conditional on the ETag from last time: { sha, etag } (sha null: unchanged). */
458
- async head (ref, etag) {
459
- const { res, notModified } = await get(`${API}/repos/${owner}/${name}/commits/${enc(ref)}`, { accept: 'application/vnd.github.sha', ...(etag ? { 'if-none-match': etag } : {}) }, `${owner}/${name} ${ref}`)
460
- if (notModified) return { sha: null, etag }
461
- const sha = (await res.text()).trim()
462
- if (!SHA.test(sha)) throw new Error(`GitHub gave no commit for ${ref}`)
463
- return { sha, etag: res.headers.get('etag') || null }
464
- },
465
- /** The repository itself: whether it can be read at all (a 404 on a branch: no such branch, or no access). */
466
- async repo () {
467
- const { res } = await get(`${API}/repos/${owner}/${name}`, { accept: 'application/vnd.github+json' }, `${owner}/${name}`)
468
- return res.json()
469
- },
470
- /** Every file at a commit: { tree: [{ path, type, mode, size }], truncated }. */
471
- async tree (sha) {
472
- const { res } = await get(`${API}/repos/${owner}/${name}/git/trees/${sha}?recursive=1`, { accept: 'application/vnd.github+json' }, `${owner}/${name} tree`)
473
- return res.json()
474
- },
475
- async compare (base, head) {
476
- const { res } = await get(`${API}/repos/${owner}/${name}/compare/${base}...${head}`, { accept: 'application/vnd.github+json' }, `${owner}/${name} compare`)
477
- return res.json()
478
- },
479
- /** The latest merged pull request from branch `ref` of this repository: { number, mergedAt, base, sha } or null. */
480
- async mergedPull (ref) {
481
- const { res } = await get(`${API}/repos/${owner}/${name}/pulls?state=closed&per_page=20&head=${encodeURIComponent(`${owner}:${ref}`)}`, { accept: 'application/vnd.github+json' }, `${owner}/${name} pull requests`)
482
- const list = await res.json()
483
- const merged = (Array.isArray(list) ? list : []).filter((p) => p && p.merged_at && p.head && p.head.ref === ref)
484
- .map((p) => ({ number: p.number, mergedAt: Date.parse(p.merged_at) || 0, base: p.base && p.base.ref, sha: p.merge_commit_sha || null }))
485
- .sort((a, b) => b.mergedAt - a.mergedAt)
486
- return merged[0] || null
487
- },
488
- /** When commit `sha` was made: ms since 1970 (its committer's date). */
489
- async committedAt (sha) {
490
- const { res } = await get(`${API}/repos/${owner}/${name}/commits/${sha}`, { accept: 'application/vnd.github+json' }, `${owner}/${name} commit`)
491
- const c = await res.json()
492
- return Date.parse(c && c.commit && c.commit.committer && c.commit.committer.date) || 0
493
- },
494
- /** A file at a commit: its bytes, null when it isn't there, or { tooLarge } past `max` bytes. */
495
- async file (sha, path, max) {
496
- const url = token ? `${API}/repos/${owner}/${name}/contents/${enc(path)}?ref=${sha}` : `${RAW}/${owner}/${name}/${sha}/${enc(path)}`
497
- if (token) spend(token)
498
- const res = await fetch(url, { headers: { ...base, accept: 'application/vnd.github.raw' }, redirect: 'follow' })
499
- if (token) heard(token, res.headers)
500
- if (res.status === 404) return null
501
- if (!res.ok) throw new GitHubError(res.status, res.headers, `${path} at ${sha.slice(0, 7)}`)
502
- const len = Number(res.headers.get('content-length'))
503
- if (Number.isFinite(len) && len > max) return { tooLarge: true }
504
- const buf = Buffer.from(await res.arrayBuffer())
505
- return buf.length > max ? { tooLarge: true } : buf
506
- }
507
- }
508
- }
509
-
510
- /**
511
- * A GitHub reader for the session's repository `repoStr` ('github.com/owner/name'), signed in the way
512
- * checkBranch is (the owner's token, the session's GitHub connection, else `fallbackToken`), or null
513
- * when it isn't on GitHub. Has compare(base, head), committedAt(sha) and mergedPull(ref) (which needs
514
- * `access` 'write': the app's read token can't see pull requests).
515
- */
516
- export async function githubReader (room, repoStr, { fetch, fallbackToken = null, access = 'read' } = {}) {
517
- const repo = parseRepo(`https://${repoStr}`)
518
- if (!repo || !repo.github) return null
519
- const own = validToken(room.meta.githubToken) ? room.meta.githubToken : ((await appCredentials(room, `${repo.owner}/${repo.name}`, { access, fresh: false })).token || null)
520
- return github({ fetch, token: own || (validToken(fallbackToken) ? fallbackToken : null), owner: repo.owner, name: repo.name })
521
- }
522
-
523
- /** How long GitHub asked to wait: Retry-After, the rate limit's reset, or twice the last wait. */
524
- export function backoffFor (err, rec, now = Date.now()) {
525
- const h = err.headers
526
- const after = Number(h && h.get && h.get('retry-after'))
527
- if (Number.isFinite(after) && after > 0) return Math.min(after * 1000, MAX_BACKOFF_MS)
528
- const reset = Number(h && h.get && h.get('x-ratelimit-reset'))
529
- if (h && h.get && h.get('x-ratelimit-remaining') === '0' && Number.isFinite(reset) && reset * 1000 > now) return Math.min(reset * 1000 - now + 1000, MAX_BACKOFF_MS)
530
- return Math.min(Math.max((rec.backoffMs || 0) * 2, FIRST_BACKOFF_MS), MAX_BACKOFF_MS)
531
- }
532
-
533
- /** A file in the branch document as planCatchUp keys it: text, "bin:<sha1>", null when absent, undefined for a stored large file. */
534
- export function docKey (e, rel) {
535
- const t = e.files.get(rel)
536
- if (t) return t.toString()
537
- const b = e.blobs.get(rel)
538
- if (b) return b.stored ? undefined : `bin:${b.hash}`
539
- return null
540
- }
541
-
542
- export const fingerprint = (k) => k === null ? 'none' : k === undefined ? 'unread' : sha1(Buffer.from(k, 'utf8'))
543
- export const hasMarkers = (k) => typeof k === 'string' && !k.startsWith('bin:') && k.split('\n').some((l) => /^(<{7}|>{7})( |$)/.test(l))
544
-
545
- const IGNORE_NAMES = ['.gitignore', '.quiltignore', '.cowoveignore']
546
- const isIgnoreFile = (rel) => IGNORE_NAMES.some((n) => rel === n || rel.endsWith(`/${n}`))
547
- const dirOf = (rel) => rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
548
-
549
- /**
550
- * What the session doesn't sync: Quilt's built-ins, the project's .gitignore files as the branch
551
- * document has them, and `extra`: [[path, text]] of ignore files read from the commit itself. A
552
- * .quiltignore never syncs (it is a built-in ignore), so a committed one is read from GitHub.
553
- */
554
- export function ignoreOf (e, extra = []) {
555
- const texts = []
556
- for (const [rel, t] of e ? e.files : []) if (isIgnoreFile(rel)) texts.push(scopeIgnore(t.toString(), dirOf(rel)))
557
- for (const [rel, t] of extra) if (typeof t === 'string') texts.push(scopeIgnore(t, dirOf(rel)))
558
- return makeIgnore(texts)
559
- }
560
-
561
- /** The .quiltignore a commit has at its root, and any other .quiltignore the change list names: [[path, text]]. */
562
- async function quiltignoresAt (gh, sha, list) {
563
- const paths = new Set(['.quiltignore'])
564
- for (const f of list) if (f && typeof f.filename === 'string' && /(^|\/)\.(quilt|cowove)ignore$/.test(f.filename) && isSafeRelPath(f.filename)) paths.add(f.filename)
565
- const out = []
566
- for (const rel of paths) {
567
- const r = await gh.file(sha, rel, 64 * 1024)
568
- if (r && !r.tooLarge) out.push([rel, r.toString('utf8')])
569
- }
570
- return out
571
- }
572
-
573
- /**
574
- * One look at branch `key` (the caller checked relayLooks). Asks GitHub for the upstream's
575
- * head; when it is strictly ahead of the recorded commit, reads every changed file at both
576
- * ends and merges them into the branch document, combining what both sides changed (the
577
- * newer edit keeps the lines both changed: the newest compared commit's time against the
578
- * session's last edit of the file). Returns what happened: { state, ... } for tests and the
579
- * log. Never throws.
580
- */
581
- export async function checkBranch (room, key, { fetch, now = () => Date.now(), log = () => {}, fallbackToken = null } = {}) {
582
- const recs = upstreamsOf(room)
583
- const rec = recs[key]
584
- if (!rec || !rec.repo || !rec.ref || !rec.sha) return { state: 'unknown' }
585
- const started = now()
586
- rec.checkedAt = started
587
- const said = (problem) => {
588
- if (problem && rec.problem !== problem) log(`[${room.name}] ${key}: ${problem}`)
589
- if (problem) rec.problem = problem; else delete rec.problem
590
- }
591
- const done = (out) => { if (!room.destroyed) { room.saveMeta(); room.broadcastBranches() } return out }
592
- const repo = parseRepo(`https://${rec.repo}`)
593
- if (!repo || !repo.github) { said(`the relay only brings in commits from GitHub, and ${rec.repo.split('/')[0]} isn't GitHub: a member's folder on ${key} brings them in`); return done({ state: 'not-github' }) }
594
- // The owner's token, else the relay's own (public repositories only), else none.
595
- // The owner's token, or the owner's GitHub connection (relay-github.js): either reads a private repository.
596
- const own = validToken(room.meta.githubToken) ? room.meta.githubToken : ((await appCredentials(room, `${repo.owner}/${repo.name}`, { access: 'read', fresh: false })).token || null)
597
- const token = own || (validToken(fallbackToken) ? fallbackToken : null)
598
- const gh = github({ fetch, token, owner: repo.owner, name: repo.name })
599
- const base = rec.sha
600
- let head, cmp
601
- const files = new Map() // path -> { base, head } as planCatchUp keys them, with binaries' bytes
602
- const bins = new Map() // "bin:<sha1>" -> bytes
603
- const tooLarge = new Set()
604
- try {
605
- const h = await gh.head(rec.ref, rec.head ? rec.etag : null)
606
- head = h.sha || rec.head
607
- rec.head = head
608
- rec.etag = h.etag
609
- delete rec.backoffMs
610
- delete rec.backoffUntil
611
- if (!head) return done({ state: 'unknown' })
612
- if (head === base) {
613
- said(null)
614
- closeLanded(room, key, rec, `Brought in at ${head.slice(0, 7)}: the session on ${key} is at ${rec.name} ${head.slice(0, 7)}.`)
615
- return done({ state: 'up-to-date', head })
616
- }
617
- cmp = await gh.compare(base, head)
618
- if (cmp.status !== 'ahead') {
619
- said(cmp.status === 'diverged'
620
- ? `${rec.name} was rewritten or moved apart from ${base.slice(0, 7)} (the session's commit on ${key}): a member's folder on ${key} has to bring it in`
621
- : null)
622
- return done({ state: cmp.status || 'unknown', head })
623
- }
624
- const list = Array.isArray(cmp.files) ? cmp.files : []
625
- if (list.length >= COMPARE_FILES) {
626
- said(`${rec.name} changed ${COMPARE_FILES} files or more since ${base.slice(0, 7)}: too many for the relay; a member's folder on ${key} brings them in`)
627
- return done({ state: 'too-many', head })
628
- }
629
- const e = room.branchDoc(key)
630
- // A diff branch (branch-view.js) is brought in through the relay's git mirror only (relay-sync.js), never over REST.
631
- if (isDiff(e)) { said(null); return done({ state: 'mirror-only', head }) }
632
- const ig = ignoreOf(e, await quiltignoresAt(gh, head, list))
633
- const want = (rel) => typeof rel === 'string' && isSafeRelPath(rel) && !isIgnored(ig, rel)
634
- const changes = new Map()
635
- for (const f of list) {
636
- if (!f || typeof f.filename !== 'string') continue
637
- const st = f.status
638
- if (st === 'renamed' && typeof f.previous_filename === 'string') {
639
- if (want(f.previous_filename)) changes.set(f.previous_filename, 'D')
640
- if (want(f.filename)) changes.set(f.filename, 'A')
641
- } else if (want(f.filename)) changes.set(f.filename, st === 'added' || st === 'copied' ? 'A' : st === 'removed' ? 'D' : 'M')
642
- }
643
- const read = async (sha, rel) => {
644
- const r = await gh.file(sha, rel, MAX_TEXT_BYTES)
645
- if (r === null) return null
646
- if (r.tooLarge) { tooLarge.add(rel); return undefined }
647
- if (looksBinary(r)) {
648
- if (r.length > LARGE_FILE_BYTES) { tooLarge.add(rel); return undefined }
649
- const k = `bin:${sha1(r)}`
650
- bins.set(k, r)
651
- return k
652
- }
653
- return r.toString('utf8')
654
- }
655
- const all = [...changes]
656
- for (let i = 0; i < all.length; i += 8) {
657
- await Promise.all(all.slice(i, i + 8).map(async ([rel, kind]) => {
658
- const [b, t] = await Promise.all([kind === 'A' ? null : read(base, rel), kind === 'D' ? null : read(head, rel)])
659
- files.set(rel, { base: b, head: t, kind })
660
- }))
661
- }
662
- } catch (err) {
663
- if (err instanceof GitHubError && rateLimited(err)) {
664
- const wait = backoffFor(err, rec, now())
665
- rec.backoffMs = wait
666
- rec.backoffUntil = now() + wait
667
- said(`GitHub asked the relay to slow down; it looks at ${rec.name} again after ${new Date(rec.backoffUntil).toISOString().slice(11, 16)} UTC`)
668
- return done({ state: 'backoff', wait })
669
- }
670
- if (err instanceof GitHubError && (err.status === 404 || err.status === 401 || err.status === 403)) {
671
- // A public repository answers this without a token: then the commit or branch is what's missing, not access.
672
- let open = false
673
- try { await github({ fetch, token: null, owner: repo.owner, name: repo.name }).repo(); open = true } catch {}
674
- if (open) {
675
- if (head && head !== base) {
676
- // The branch is there but the recorded commit isn't: a folder's local commit that was never
677
- // pushed (and may be gone). Nobody can descend from it, so the record would never move:
678
- // follow the branch's head instead. Nothing is brought in from a commit the relay can't
679
- // read; the session's files stay as they are, and folders bring real changes in as always.
680
- rec.sha = head
681
- delete rec.relayHead
682
- rec.recoveredAt = now()
683
- said(null) // healed: nothing to show members
684
- log(`[${room.name}] ${key}: ${base.slice(0, 7)} (the commit the session's files were measured from) isn't on GitHub; following ${rec.name} at ${head.slice(0, 7)} now`)
685
- return done({ state: 'recovered', head })
686
- }
687
- if (!head && key !== room.resolveKey?.(DEFAULT_KEY) && key !== room.defaultKey) {
688
- // The branch itself is gone from GitHub (deleted after its merge, as an agent's branch is): nothing more to bring in.
689
- // The branch stays in the session; a folder that reports it again (recreated) brings the record back (noteFolders).
690
- rec.gone = now()
691
- said(`${rec.name} isn't on GitHub any more (deleted after its merge?): nothing more comes in; the branch stays in the session as it is`)
692
- return done({ state: 'gone' })
693
- }
694
- said(`${rec.name} or ${base.slice(0, 7)} (the commit the session's files on ${key} are at) isn't on GitHub (not pushed yet, or the branch is gone): the relay waits for a member's folder on ${key} at a pushed commit`)
695
- return done({ state: 'not-on-github' })
696
- }
697
- said(own ? tokenRefused(rec.repo) : noToken(rec.repo))
698
- return done({ state: own ? 'token-refused' : 'private' })
699
- }
700
- said(`the relay couldn't reach GitHub for ${rec.name} (${String(err.message).slice(0, 120)}); it tries again later`)
701
- return done({ state: 'error' })
702
- }
703
- // Everything is read: from here on nothing waits, so the document can't change under the plan.
704
- if (room.ended || room.destroyed || !room.meta.branches[key] || recs[key] !== rec || rec.sha !== base || folderOn(room, key)) return done({ state: 'moved-on' })
705
- const e = room.branchDoc(key)
706
- const behind = Number.isFinite(cmp.ahead_by) ? cmp.ahead_by : Number.isFinite(cmp.total_commits) ? cmp.total_commits : 0
707
- const changes = new Map([...files].map(([rel, f]) => [rel, f.kind]))
708
- const baseMap = new Map([...files].map(([rel, f]) => [rel, f.base]))
709
- const theirs = new Map([...files].map(([rel, f]) => [rel, f.head]))
710
- const disk = (rel) => docKey(e, rel)
711
- // Which edit is newer, for the lines both sides changed: the session's last edit of the file against GitHub's newest commit here.
712
- const commits = Array.isArray(cmp.commits) ? cmp.commits : []
713
- const newest = commits.at(-1) || null
714
- const ghAt = newest && newest.commit ? Date.parse((newest.commit.committer && newest.commit.committer.date) || (newest.commit.author && newest.commit.author.date) || '') || 0 : 0
715
- const ghSide = newest ? { name: (newest.commit && newest.commit.author && newest.commit.author.name) || (newest.author && newest.author.login) || rec.name, sha: newest.sha || head, ts: ghAt } : null
716
- const parties = new Map()
717
- const partyOf = (rel) => { if (!parties.has(rel)) parties.set(rel, lastEditor(e, rel)); return parties.get(rel) }
718
- const oursNewer = (rel) => ((partyOf(rel) || {}).ts || 0) > ghAt + 999 // a commit's whole second is its own
719
- const union = unionOf(e)
720
- const plan = planCatchUp({ changes, base: baseMap, theirs, disk, published: publishedOf(room.doc), oursNewer, union })
721
- if (plan.conflicts.length) {
722
- // Files the relay can't read (too large for it): nothing comes in until a member's folder brings them in (all or nothing: one commit is recorded).
723
- const list = plan.conflicts.map((c) => c.path)
724
- said(`${rec.name} changes ${list.slice(0, 4).join(', ')}${list.length > 4 ? ' and more' : ''}, too large for the relay to read: a member's folder on ${key} brings ${list.length === 1 ? 'it' : 'them'} in`)
725
- return done({ state: 'too-large', head, files: list })
726
- }
727
- // A file someone holds is never written under them (as a folder's bring-in waits, Session.bringIn):
728
- // only the clash's files, held for its merge. The bring-in waits until they let go of it.
729
- for (const rel of plan.writes.keys()) {
730
- const c = room.claimFor ? room.claimFor(rel, key) : null
731
- if (c) {
732
- said(`${c.by} holds ${rel}${c.pattern && c.pattern !== rel ? ` (their claim on ${c.pattern})` : ''}: the relay brings ${rec.name} in once they let go of it (quilt release ${c.pattern || rel}, or quilt_handoff)`)
733
- rec.waitingOn = { path: rel, by: c.by, pattern: c.pattern } // looked at again as soon as that claim goes (Room.noteClaimsChanged)
734
- return done({ state: 'waiting', head, held: rel, by: c.by })
735
- }
736
- }
737
- delete rec.waitingOn
738
- // Clean: the whole bring-in in one transaction from the relay, with one line in the activity log.
739
- const history = e.historyLog || (e.historyLog = new HistoryLog(e.doc, e.doc.getArray('history'), { origin: ORIGIN }))
740
- let written = 0
741
- e.doc.transact(() => {
742
- for (const [rel, k] of plan.writes) {
743
- const before = e.files.get(rel)?.toString()
744
- const existed = e.files.has(rel) || e.blobs.has(rel)
745
- if (k === null) {
746
- e.files.delete(rel)
747
- e.blobs.delete(rel)
748
- history.record({ by: RELAY_BY, path: rel, kind: 'deleted', before, after: '', pulled: true })
749
- } else if (k.startsWith('bin:')) {
750
- const buf = bins.get(k)
751
- e.files.delete(rel)
752
- e.blobs.set(rel, { hash: k.slice(4), data: buf.toString('base64') })
753
- history.record({ by: RELAY_BY, path: rel, kind: existed ? 'edited' : 'created', detail: `${buf.length} bytes`, pulled: true })
754
- } else {
755
- e.blobs.delete(rel)
756
- let y = e.files.get(rel)
757
- if (!y) { y = new Y.Text(); e.files.set(rel, y) }
758
- applyTextDiff(y, k)
759
- history.record({ by: RELAY_BY, path: rel, kind: existed ? 'edited' : 'created', before: before ?? '', after: k, pulled: true })
760
- }
761
- written++
762
- }
763
- }, ORIGIN)
764
- const detail = `${plural(behind, 'commit')} from ${rec.name} · ${plural(written, 'file')}`
765
- room.doc.transact(() => {
766
- room.activity.push([{ by: RELAY_BY, path: '', kind: 'brought', detail, branch: key, ts: now() }])
767
- if (room.activity.length > 300) room.activity.delete(0, room.activity.length - 300)
768
- }, ORIGIN)
769
- log(`[${room.name}] ${key}: ${RELAY_BY} brought in ${detail} (${base.slice(0, 7)}..${head.slice(0, 7)}; no folder on it is online)`)
770
- rec.sha = head
771
- rec.relayHead = head
772
- rec.brought = { count: behind, files: written, at: now(), head }
773
- // Whoever's lines lost is told: a direct message for the session's, the activity log for GitHub's (relay-clash.js).
774
- const { tellAutoMerge } = await import('./relay-clash.js')
775
- for (const m of plan.merged) tellAutoMerge(room, key, { path: m.path, lost: m.lost, deleted: m.deleted || null, session: partyOf(m.path), github: ghSide, upstream: rec.name, head, now: now() })
776
- said(null)
777
- closeLanded(room, key, rec, `Brought in at ${head.slice(0, 7)}: ${RELAY_BY} merged ${plural(behind, 'commit')} from ${rec.name} into ${key}.`)
778
- return done({ state: 'brought', head, count: behind, files: written, merged: plan.merged.map((m) => ({ path: m.path, lost: m.lost.length ? m.lost[0].side : null, ...(m.deleted ? { deleted: m.deleted } : {}) })) })
779
- }
780
-
781
- /** The relay's clash task on `key` is done: the session is at or past its commit. */
782
- export function closeLanded (room, key, rec, verified) {
783
- if (!rec.clash) return
784
- const t = readTasks(room.tasks).find((x) => x.id === rec.clash.task)
785
- if (t && t.column !== 'done') {
786
- try { updateTask(room.doc, room.tasks, { id: t.id, column: 'done', verified }, ORIGIN) } catch {}
787
- }
788
- delete rec.clash
789
- }
790
-
791
- /** The member who last changed `rel` on a branch document (not the relay, not a pull): { name, ts }, or null. */
792
- function lastEditor (e, rel) {
793
- let best = null
794
- for (const h of e.doc.getArray('history').toArray()) if (h && h.path === rel && !h.pulled && !h.seed && h.by && !isRelay(h.by) && (!best || (h.ts || 0) >= best.ts)) best = { name: h.by, ts: h.ts || 0 }
795
- return best
796
- }
797
-
798
- /** The merge=union matcher from the session's .gitattributes files. */
799
- function unionOf (e) {
800
- const texts = []
801
- for (const [rel, t] of e.files) if (rel === '.gitattributes' || rel.endsWith('/.gitattributes')) texts.push([rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '', t.toString()])
802
- return unionMatcher(texts)
803
- }
804
-
805
- /** What a hosted agent (or a member's AI) is told when the relay can't read a private repository. */
806
- export const noToken = (repo) => `the relay can't read ${repo}: ask the session owner to add a read-only GitHub token (session settings, or quilt_github_token)`
807
- export const tokenRefused = (repo) => `the relay's GitHub token can't read ${repo}: ask the session owner to give it read access to that repository's contents (session settings, or quilt_github_token)`
808
-
809
- const ago = (ts, now = Date.now()) => {
810
- const s = Math.max(0, Math.round((now - ts) / 1000))
811
- return s < 60 ? `${s}s ago` : s < 3600 ? `${Math.round(s / 60)}m ago` : `${Math.round(s / 3600)}h ago`
812
- }
813
-
814
- /**
815
- * What one look at branch `key` (checkBranch's result `r`) means, for an AI that asked for it
816
- * (quilt_sync_branch): in the words describeBranchSync uses for a folder.
817
- */
818
- export function describeRelayCheck (r, rec, key, room = null) {
819
- const up = (rec && rec.name) || 'its upstream'
820
- if (!r) return `The relay didn't look at ${up} for \`${key}\`.`
821
- if (r.state === 'brought') {
822
- const lost = (r.merged || []).filter((m) => m.lost)
823
- return `Brought ${plural(r.count, 'commit')} from ${up} into \`${key}\` (${plural(r.files, 'file')}), merged with the session's uncommitted work${lost.length ? `; where both changed the same lines of ${lost.map((m) => m.path).slice(0, 4).join(', ')}, the newer edit was kept (the other lines are in the file's history)` : ''}. \`${key}\` is up to date with ${up} (${r.head.slice(0, 7)}).`
824
- }
825
- if (r.state === 'up-to-date') return `\`${key}\`: up to date with ${up} (${r.head.slice(0, 7)}).`
826
- if (r.state === 'too-large') return `Nothing brought in yet: ${up} (${r.head.slice(0, 7)}) changes ${(r.files || []).join(', ')}, too large for the relay to read; a member's folder on \`${key}\` brings ${(r.files || []).length === 1 ? 'it' : 'them'} in. Nothing to merge by hand.`
827
- if (r.state === 'moved-on') return `A folder on \`${key}\` that brings commits in itself came online, so the relay left it to that folder.`
828
- if (r.state === 'recovered') return `The commit the session's files on \`${key}\` were measured from wasn't on GitHub (a folder's local commit, since dropped); the relay follows ${up} at ${r.head.slice(0, 7)} now. Nothing to bring in from a commit it can't read; the session's files stay as they are.`
829
- if (r.state === 'gone') return `Nothing brought in: ${up} isn't on GitHub any more (deleted after its merge?). \`${key}\` stays in the session as it is.`
830
- if (rec && rec.problem) return `Nothing brought in: ${rec.problem}.`
831
- return `The relay couldn't tell where ${up} is now; it looks again by itself.`
832
- }
833
-
834
- /**
835
- * The files of `ref` on GitHub at its head, for starting a session branch from it (a hosted
836
- * agent's quilt_switch_branch). Ignored paths (built-ins, the commit's .gitignore and
837
- * .quiltignore files) and unsafe ones are left out, and so are files too large for the relay.
838
- * Returns { state: 'loaded', head, texts: Map, blobs: Map, skipped: [path], bytes } or
839
- * { state: 'no-branch' | 'private' | 'token-refused' | 'too-many' | 'backoff' | 'error', ... }.
840
- */
841
- export async function loadBranch ({ repo, ref, token = null, fetch, maxFiles = LOAD_FILES }) {
842
- const r = parseRepo(`https://${repo}`)
843
- if (!r || !r.github) return { state: 'not-github' }
844
- const gh = github({ fetch, token, owner: r.owner, name: r.name })
845
- try {
846
- let head
847
- try { head = (await gh.head(ref)).sha } catch (err) {
848
- if (!(err instanceof GitHubError) || (err.status !== 404 && err.status !== 422)) throw err
849
- try { await gh.repo() } catch (e2) {
850
- if (e2 instanceof GitHubError && (e2.status === 404 || e2.status === 401)) return { state: token ? 'token-refused' : 'private' }
851
- throw e2
852
- }
853
- return { state: 'no-branch' }
854
- }
855
- const tree = await gh.tree(head)
856
- const all = (Array.isArray(tree.tree) ? tree.tree : []).filter((x) => x && x.type === 'blob' && typeof x.path === 'string' && x.mode !== '120000' && isSafeRelPath(x.path))
857
- if (tree.truncated || all.length > maxFiles) return { state: 'too-many', head, count: all.length }
858
- const ignores = []
859
- for (const x of all) {
860
- if (!isIgnoreFile(x.path)) continue
861
- const b = await gh.file(head, x.path, 64 * 1024)
862
- if (b && !b.tooLarge) ignores.push([x.path, b.toString('utf8')])
863
- }
864
- const ig = ignoreOf(null, ignores)
865
- const wanted = all.filter((x) => !isIgnored(ig, x.path))
866
- const texts = new Map()
867
- const blobs = new Map()
868
- const skipped = []
869
- let bytes = 0
870
- for (let i = 0; i < wanted.length; i += 8) {
871
- await Promise.all(wanted.slice(i, i + 8).map(async (x) => {
872
- if (Number.isFinite(x.size) && x.size > MAX_TEXT_BYTES) { skipped.push(x.path); return }
873
- const buf = await gh.file(head, x.path, MAX_TEXT_BYTES)
874
- if (!buf) return
875
- if (buf.tooLarge) { skipped.push(x.path); return }
876
- if (looksBinary(buf)) {
877
- if (buf.length > LARGE_FILE_BYTES) { skipped.push(x.path); return }
878
- blobs.set(x.path, { hash: sha1(buf), data: buf.toString('base64') })
879
- } else texts.set(x.path, buf.toString('utf8'))
880
- bytes += buf.length
881
- }))
882
- }
883
- return { state: 'loaded', head, texts, blobs, skipped: skipped.sort(), bytes }
884
- } catch (err) {
885
- if (err instanceof GitHubError && rateLimited(err)) return { state: 'backoff' }
886
- if (err instanceof GitHubError && (err.status === 404 || err.status === 401 || err.status === 403)) return { state: token ? 'token-refused' : 'private' }
887
- return { state: 'error', message: String(err.message).slice(0, 160) }
888
- }
889
- }