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
package/src/server.js CHANGED
@@ -14,7 +14,7 @@
14
14
  // editors change files, viewers only watch and chat, and agents can be
15
15
  // limited to some folders. The relay enforces it by undoing file changes a
16
16
  // member isn't allowed to make, before anyone else sees them.
17
- import { quiltOf, gitTokenRefusal } from './session-seam.js'
17
+ import { quiltOf } from './session-seam.js'
18
18
  import http from 'node:http'
19
19
  import { makeChatLink, extendChatLink, pruneChatLinks, handleChatLink, fetchPublicFile } from './chat-links.js'
20
20
  import fs from 'node:fs'
@@ -45,7 +45,7 @@ import { githubRepoOf, installationFor, remoteStage, mainGuarded, rulesetSteps,
45
45
  import { syncStoppedWords } from './ui/force-words.js'
46
46
  import { historyQuilt, linkedHistory } from './quilt-history.js'
47
47
  import { MAX_DIFF_CHARS } from './history.js'
48
- import { LAYOUT_QUILT, copiedHead, treeIds, treeModes, branchFiles, binariesToUpload, bringsNew, forwardIntoLive, applyChecked, foldBack, writeFold, overlayFor, checkOverlay, copyIntoLive, carryLive, takeGitMeta, restoreGitMeta, squareBase, shareWithPeople, changedGitIds, dropOldCopies, boxOntoHead, writeIntoLive } from './session-layout3.js'
48
+ import { LAYOUT_QUILT, copiedHead, treeIds, treeModes, branchFiles, binariesToUpload, bringsNew, forwardIntoLive, applyChecked, foldBack, writeFold, overlayFor, checkOverlay, copyIntoLive, carryLive, takeGitMeta, restoreGitMeta, squareBase, shareWithPeople, boxOntoHead, writeIntoLive } from './session-layout3.js'
49
49
  import { readMerges } from './merges.js'
50
50
  import { readTasks, updateTask } from './tasks.js'
51
51
  import { addComment } from './task-comments.js'
@@ -69,27 +69,14 @@ import { makeSubscription, publicWebhookHost } from './webhooks.js'
69
69
  import { noteCloudAi, cloudAiWaker } from './relay-ai-vm.js'
70
70
  import { normalizeHosted, hostedIn, newHosted } from './hosted-agents.js'
71
71
  import { ChatArchive } from './chat-archive.js'
72
- import { asksRelay, relayAnswer, mergedBranches } from './relay-ask.js'
73
- import { DIFF, markDiff, isDiff, hasEntry, cutOf, notePresent, removePath, gitOf, cachedTree, baseOf, ensureCommit, mirrorFor } from './branch-view.js'
74
- import { inactiveBranches, mergedByAncestry, mergedByPull, samePatches, deleteOnGithub, pruneNotice, ARCHIVE_KEEP_MS, ANCESTRY_EVERY_MS } from './relay-prune.js'
75
- import { githubReader } from './relay-upstream.js'
76
- import { RELAY_BY, isRelay } from './branches.js'
77
- import { commitForMember, agentCommitsOf, POLICY_RANK, ruleSetter, agentsCanCommit, pullRequestsFor, mergeForMember, unpublishedFor, remindUnpublished, notePushedFor, repoRecordFor, finishTaskFor, reviewPulls } from './relay-commit.js'
78
- import { appCredentials, revokeGitHubToken, noteMemberLogins } from './relay-github.js'
79
- import { mirrorManager, syncStateOf, runGit, fetchBranch } from './relay-git.js'
72
+ import { asksRelay, relayAnswer } from './relay-ask.js'
73
+ import { DIFF, isDiff, hasEntry } from './branch-view.js'
74
+ import { inactiveBranches, pruneNotice, ARCHIVE_KEEP_MS } from './relay-prune.js'
75
+ import { RELAY_NAME as RELAY_BY, isRelay } from './ui/chat.js'
80
76
  import { leaseKeeper, LEASE_HEARTBEAT_MS } from './relay-lease.js'
81
- import { bringInBranch, BACKSTOP_MS, ACTIVE_BACKSTOP_MS, syncStatus, markFolderBrought } from './relay-sync.js'
82
- import { noteCiResult } from './relay-ci.js'
83
- import { outboundFor, replayBundle, liveSyncOn, adoptBranch, receiveAgentPush } from './relay-outbound.js'
84
- import { ensureView, syncViewRefs, serveGit, parseUpdates, viewRepoOf, viewDir, gitHttpBackendWorks } from './relay-githttp.js'
85
- import { relayFileKey } from './relay-filekey.js'
86
77
  import { RoomMeter, roomDiskBytes, MachineMeter, machineLoad } from './relay-metrics.js'
87
78
  import { clusterConfig, clusterProblems, Cluster, NOT_ROOMS, roomOfPath, replaySource, canReplay, displaceRoom, packRoom, unpackRoom, placeStaged, removeLocalRoom, MOVING, ELSEWHERE } from './relay-cluster.js'
88
79
  import { joiningRoom, sessionArg } from './mcp-room.js'
89
- import { clashAction, noteMergeChecks } from './relay-clash.js'
90
- import { AGENT_COMMITS } from './github-commit.js'
91
- import { repoMoves } from './relay-repo-moves.js'
92
- import { UPSTREAM_CHECK_MS, SYNC_EVERY_MS, SYNC_EVERY_GIT_MS, upstreamsOf, noteFolders, applyRepoMove, relayEntry, relayLooks, dueBranches, checkBranch, folderOn, connectedOn, hostedOn, validToken, folderAble, catcherAble, noteHolds, connectedAs, describeRelayCheck, loadBranch, parseRepo, configureGithubBudget, CLASH_IDLE_MS, noteConnection } from './relay-upstream.js'
93
80
  import { nowSummary } from './now-summary.js'
94
81
  import { squareShort } from '@heyquilt/seam/lib/quilts/errors.js'
95
82
 
@@ -108,7 +95,6 @@ const MAX_PATTERN = 500
108
95
  const CONTROL_CHARS = /[\u0000-\u001f\u007f]/ // never in a claim: branch claims are keyed `<branch>\0<pattern>`
109
96
  // A room's documents together (the room's and every branch's) may take this many times the size limit before new branches are refused.
110
97
  const MAX_TOTAL_FACTOR = 10
111
- const GITHUB_REPO = /^github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/i
112
98
  const newEpoch = () => crypto.randomBytes(8).toString('hex')
113
99
  // The file queue: requests for a claimed file (a title, and a summary of the plan), and handoffs.
114
100
  const MAX_REQUEST_TITLE = 120
@@ -146,8 +132,6 @@ const hash = (s) => crypto.createHash('sha256').update(String(s)).digest()
146
132
  const sameSecret = (a, b) => a.length === b.length && crypto.timingSafeEqual(a, b)
147
133
 
148
134
  /** Relay settings, from options or environment variables. */
149
- // How often a room asks the accounts API what agents may commit (its org's or workspace's choice), besides being told.
150
- const POLICY_EVERY_MS = 10 * 60 * 1000
151
135
 
152
136
  export function relayConfig (opts = {}) {
153
137
  const env = adoptLegacyEnv({ ...process.env })
@@ -171,40 +155,10 @@ export function relayConfig (opts = {}) {
171
155
  idleUnloadMs: num(opts.idleUnloadMs, 60 * 1000),
172
156
  // A branch document nobody is on leaves memory this long after its last use (the room may stay).
173
157
  branchIdleMs: num(opts.branchIdleMs, BRANCH_IDLE_MS),
174
- // How often the relay asks GitHub about a branch only hosted agents are working on (relay-upstream.js),
175
- // and whether it does by itself (tests turn that off and ask with checkUpstreams).
176
- upstreamCheckMs: num(opts.upstreamCheckMs, UPSTREAM_CHECK_MS),
177
- upstreamAuto: opts.upstreamAuto !== false,
178
- // A read-only GitHub token of the relay's own (a Fly secret, public repositories only), used when
179
- // a session has none: GitHub allows 60 requests an hour per IP without one, 5000 with.
180
- githubToken: opts.githubToken ?? env.QUILT_GITHUB_TOKEN ?? '',
181
- // GitHub as a node (relay-sync.js): commits come in through the relay's git mirror, on by default; QUILT_GIT_SYNC=0 keeps the REST path.
182
- gitSync: opts.gitSync ?? env.QUILT_GIT_SYNC !== '0',
183
- gitRemote: typeof opts.gitRemote === 'function' ? opts.gitRemote : null, // tests: a repository slug -> where its git is
184
- // Agents' folders use git through the relay (relay-githttp.js); off when this machine's git has no http-backend.
185
- gitHttp: opts.gitHttp !== false,
186
- gitHttpCommand: typeof opts.gitHttpCommand === 'string' ? opts.gitHttpCommand : 'git', // tests: a git without http-backend
187
- syncAuto: opts.syncAuto === true, // the relay's own commit timer: off; commits happen when someone asks for one (quilt_git_commit, a git push)
188
- crashAt: typeof opts.crashAt === 'string' ? opts.crashAt : null, // tests: where to inject a crash (relay-sync.js crashPoint)
158
+ // The relay's leases (relay-lease.js: a layout move holds one per room); tests shorten them.
189
159
  leaseTtlMs: num(opts.leaseTtlMs, undefined),
190
- pushGapMs: num(opts.pushGapMs ?? env.QUILT_PUSH_GAP_MS, undefined),
191
- pushesPerHour: num(opts.pushesPerHour, undefined),
192
- activeBackstopMs: num(opts.activeBackstopMs, undefined),
193
- backoffStartMs: num(opts.backoffStartMs, undefined),
194
- backoffMaxMs: num(opts.backoffMaxMs, undefined),
195
160
  leaseMarginMs: num(opts.leaseMarginMs, undefined),
196
161
  leaseHeartbeatMs: num(opts.leaseHeartbeatMs, undefined),
197
- atPoint: typeof opts.atPoint === 'function' ? opts.atPoint : null,
198
- folderAheadWaitMs: num(opts.folderAheadWaitMs, undefined),
199
- syncQuietMs: num(opts.syncQuietMs, undefined),
200
- syncMaxMs: num(opts.syncMaxMs, undefined),
201
- // How many GitHub API requests an hour the relay makes without a token, for every session together.
202
- githubAnonPerHour: num(opts.githubAnonPerHour ?? env.QUILT_GITHUB_ANON_PER_HOUR, 50),
203
- // A clash assignee with no progress this long is handed on to the next agent (relay-upstream.js).
204
- clashIdleMs: num(opts.clashIdleMs, CLASH_IDLE_MS),
205
- // Whether a write to a file in an open relay clash (or its task moved to QA or Done by its
206
- // assignee) makes the relay look again at once; on with upstreamAuto unless set.
207
- upstreamOnChange: opts.upstreamOnChange ?? opts.upstreamAuto !== false,
208
162
  // A claim whose holder has done nothing in the session this long is let go (see sweepClaims):
209
163
  // handed to the first one waiting in its file queue, or released.
210
164
  claimIdleMs: num(opts.claimIdleMs, 20 * 60 * 1000),
@@ -219,20 +173,6 @@ export function relayConfig (opts = {}) {
219
173
  }
220
174
  }
221
175
 
222
- /** The files a transaction changed in a branch document (entries in files or blobs, or text inside one, or a diff branch's deletions). */
223
- function changedPaths (tr, e) {
224
- const out = new Set()
225
- for (const [type, events] of tr.changedParentTypes) {
226
- if (type !== e.files && type !== e.blobs && type !== e.gone) continue
227
- for (const ev of events) {
228
- if (ev.target === type) { for (const k of ev.changes.keys.keys()) out.add(k); continue }
229
- let t = ev.target
230
- while (t && t._item && t._item.parent !== type) t = t._item.parent
231
- if (t && t._item && t._item.parentSub) out.add(t._item.parentSub)
232
- }
233
- }
234
- return out
235
- }
236
176
 
237
177
 
238
178
  /** Who loaded a branch document, from the stack: the first two callers outside the branch store. */
@@ -284,10 +224,6 @@ class Room {
284
224
  // Each branch's files live in a document of their own (branchdocs.js); the session's
285
225
  // branches are key -> { by, at, base, seen, stored }, and the default is the first one joined.
286
226
  this.meta.branches = this.meta.branches || {}
287
- // With the mirror on, every synced branch is fetched again as soon as someone is on it: a webhook may have been missed while the relay was down (plan §5.1).
288
- if (cfg.gitSync && this.meta.upstreams && typeof this.meta.upstreams === 'object') for (const rec of Object.values(this.meta.upstreams)) if (rec && rec.repo) { rec.checkedAt = 0; delete rec.webhookAt }
289
- // CI results the relay hadn't noted when it last stopped (noteChecks): noted now, in the order they came.
290
- if (Array.isArray(this.meta.ciPending) && this.meta.ciPending.length) setTimeout(() => { if (!this.destroyed) for (const ev of this.meta.ciPending.slice()) this.runCi(ev) }, 500).unref?.()
291
227
  // Branches taken out of the session with work that may be nowhere else (relay-prune.js): kept here a while, out of the session's size.
292
228
  this.archiveDir = dataDir && path.join(dataDir, 'branches-archive', name)
293
229
  this.store = new BranchStore({
@@ -398,25 +334,6 @@ class Room {
398
334
  }
399
335
  const msg = awarenessMessage(this.awareness, changed)
400
336
  for (const ws of this.conns.keys()) if (!ws.roomless) send(ws, msg)
401
- // Which upstream each branch follows, and where its folders are (relay-upstream.js).
402
- if (origin && this.conns.has(origin)) this.noteUpstreams()
403
- })
404
- this.githubFetch = globalThis.fetch // set by startServer (tests give their own: never the network)
405
- this.upstreamRun = null // the relay's look at GitHub under way (checkUpstreams)
406
- this.syncAsked = new Map() // branch -> when quilt_sync_branch last made the relay ask GitHub (syncUpstreamNow)
407
- this.upstreamSoonKeys = new Set() // branches to look at again at once (upstreamSoon)
408
- this.lookedFor = new WeakSet() // connections whose arrival made their branch due a look (reconnect reconciliation)
409
- // A relay clash's task moved to QA or Done by its assignee: the files count as merged (relay-upstream.js).
410
- this.tasks.observe((ev, tr) => {
411
- for (const [id, c] of ev.changes.keys) {
412
- const t = this.tasks.get(id)
413
- // A clash task done or gone: its files are let go of, whoever held them for it (online or not).
414
- if (!t || t.column === 'done') this.dropClashClaims(id)
415
- if (tr.origin === 'relay-upstream') continue
416
- // Moved (or moved again, after someone else did) to QA or Done: whose move it was decides.
417
- if (c.action !== 'update' || !t || (t.column !== 'qa' && t.column !== 'done')) continue
418
- this.noteClashMoved(id, t, tr.origin)
419
- }
420
337
  })
421
338
  }
422
339
 
@@ -721,7 +638,6 @@ class Room {
721
638
  hostedActive (id) {
722
639
  const was = this.hostedSeen.get(id) || 0
723
640
  this.hostedSeen.set(id, Date.now())
724
- this.noteHostedUpstream(id)
725
641
  this.noteActivity([id])
726
642
  for (const [k, p] of this.pending) if (k.hosted && p.id === id) this.pending.delete(k)
727
643
  // Newly online, or a newer check-in time: everyone's member list shows it (once a minute at most).
@@ -752,27 +668,6 @@ class Room {
752
668
  broadcastClaims () {
753
669
  this.saveMeta()
754
670
  for (const other of this.conns.keys()) send(other, jsonMessage(MSG_CLAIMS, { claims: this.claimList(other.branch) }))
755
- this.noteClaimsChanged()
756
- }
757
-
758
- /** A claim the relay's bring-in waits on (rec.waitingOn) was let go of, handed on or swept: that branch is looked at again at once. */
759
- noteClaimsChanged () {
760
- if (this.cfg.gitSync && this.mirrors) for (const key of Object.keys(this.meta.sync || {})) if (Object.keys((this.meta.sync[key] || {}).pending || {}).length) this.outbound.schedule(key)
761
- for (const [key, rec] of Object.entries(this.meta.upstreams || {})) {
762
- const w = rec && rec.waitingOn
763
- if (!w) continue
764
- const c = this.meta.branches[key] ? this.claimFor(w.path, key) : null
765
- if (c && c.by === w.by && c.pattern === w.pattern) continue
766
- delete rec.waitingOn
767
- this.upstreamSoon(key)
768
- }
769
- // A file held for a claim that is gone now (its note above may have been cleared by a later look): looked at again.
770
- if (this.cfg.gitSync && this.mirrors) {
771
- for (const [key, st] of Object.entries(this.meta.sync || {})) {
772
- if (!st || !this.meta.branches[key]) continue
773
- if ((st.held || []).some((h) => h.why === 'claimed' && !this.claimFor(h.path, key))) this.upstreamSoon(key)
774
- }
775
- }
776
671
  }
777
672
 
778
673
  /** May this connection change this file? */
@@ -982,23 +877,12 @@ class Room {
982
877
  e.doc.on('update', (update, origin, doc, tr) => {
983
878
  if (origin === e.guard && e.undoing) { e.undoing.push(update); return } // sent merged, by checkChange
984
879
  if (origin && origin !== e.guard && this.guard.trackedOrigins.has(origin) && !this.checkChange(origin, update, tr, e)) return
985
- // Written on in the session (not the relay bringing GitHub in): a merged branch with this after its merge keeps its work (trimMergedBranches).
986
- if (origin && origin !== 'relay-upstream' && this.meta.branches[e.key]) this.meta.branches[e.key].changed = Date.now()
880
+ // Written on in the session: an idle branch counts from its last change (relay-prune.js).
881
+ if (origin && this.meta.branches[e.key]) this.meta.branches[e.key].changed = Date.now()
987
882
  if (origin && this.conns.has(origin)) {
988
883
  this.noteActivity(this.holderKeys(origin))
989
884
  if (this.cloudAiWaker) this.cloudAiWaker.personActive(this, origin).catch(() => {})
990
885
  }
991
- // A member (hosted or connected) wrote a file in the relay's open clash on this branch: it looks again at once.
992
- const rec = origin !== 'relay-upstream' && this.meta.upstreams ? this.meta.upstreams[e.key] : null
993
- if (rec && rec.clash) for (const rel of changedPaths(tr, e)) this.noteClashWrite(e.key, rel)
994
- // GitHub as a node: a member's change on a synced branch is pending for the next commit (relay-outbound.js).
995
- if (origin !== 'relay-upstream' && this.cfg.gitSync && this.mirrors && rec) for (const rel of changedPaths(tr, e)) this.outbound.note(e.key, rel)
996
- // A file edited in the session is no longer GitHub's version: its "from GitHub" mark goes (relay-sync.js markFromGithub).
997
- if (origin !== 'relay-upstream') {
998
- const gh = e.doc.getMap('fromGithub')
999
- const gone = gh.size ? [...changedPaths(tr, e)].filter((rel) => gh.has(rel)) : []
1000
- if (gone.length) e.doc.transact(() => { for (const rel of gone) gh.delete(rel) }, 'relay-upstream')
1001
- }
1002
886
  for (const ws of e.conns) if (ws !== origin) send(ws, updateMessage(update, ws.branchAs))
1003
887
  const was = e.bytes > this.cfg.maxRoomBytes
1004
888
  e.bytes += update.length
@@ -1022,31 +906,6 @@ class Room {
1022
906
  return !!sq && (sq.files.has(rel) || sq.blobs.has(rel))
1023
907
  }
1024
908
 
1025
- /** Whether a new branch here may be a diff one: the relay keeps a git mirror of the session's GitHub repository. */
1026
- diffCapable (repo = '') {
1027
- if (!this.cfg.gitSync || !this.mirrors) return false
1028
- // A new session knows its repository once a folder reports it; until then, the folder joining says which (`repo`).
1029
- if (typeof repo === 'string' && GITHUB_REPO.test(repo)) return true
1030
- return Object.values(upstreamsOf(this)).some((rec) => rec && typeof rec.repo === 'string' && /^github\.com\//i.test(rec.repo))
1031
- }
1032
-
1033
- /**
1034
- * Whether a branch a hosted agent starts may be a diff one: the relay can, and every app connected
1035
- * here can open one (an older app would be sent to update the moment it checked the branch out).
1036
- */
1037
- diffForHosted () {
1038
- if (!this.diffCapable()) return false
1039
- for (const ws of this.conns.keys()) if (!ws.hosted && !(ws.features || []).includes('branch-diffs')) return false
1040
- return true
1041
- }
1042
-
1043
- /** Makes branch `k`'s new, empty document a diff one from commit `base` (its meta and the document say so). */
1044
- startDiff (k, e, base, repo = '') {
1045
- markDiff(e, { base, origin: 'relay-upstream' })
1046
- const b = this.meta.branches[k]
1047
- if (b) { b.form = DIFF; b.diffBase = base; if (typeof repo === 'string' && GITHUB_REPO.test(repo)) b.repo = repo.toLowerCase() }
1048
- }
1049
-
1050
909
  /** A branch document was saved: records it was used just now (see noteBranchSeen), and the
1051
910
  * stored files it points at are remembered for when it is unloaded (storedIds). */
1052
911
  noteBranchSaved (e) {
@@ -1164,66 +1023,6 @@ class Room {
1164
1023
  }
1165
1024
  }
1166
1025
 
1167
- /**
1168
- * Merges the webhook never told (relay-prune.js mergedByPull: a branch's merged pull request,
1169
- * asked hourly) and merges made without a pull request (mergedByAncestry: asks GitHub at most every
1170
- * ANCESTRY_EVERY_MS whether each branch's commits are all in its base), then trims what merged.
1171
- */
1172
- async findMergedBranches (now = Date.now()) {
1173
- if (this.destroyed || this.ended || this.findingMerges || now - (this.mergesLookedAt || 0) < ANCESTRY_EVERY_MS) return []
1174
- this.findingMerges = true
1175
- this.mergesLookedAt = now
1176
- try {
1177
- const readers = new Map()
1178
- const readerWith = (access) => async (repo) => {
1179
- if (!readers.has(`${access} ${repo}`)) readers.set(`${access} ${repo}`, await githubReader(this, repo, { fetch: this.githubFetch, fallbackToken: this.cfg.githubToken, access }))
1180
- return readers.get(`${access} ${repo}`)
1181
- }
1182
- this.pullsLookedAt = this.pullsLookedAt || new Map()
1183
- // Pull requests are only seen with the app's write token (the one that deletes merged branches).
1184
- const marked = [...await mergedByPull(this, { reader: readerWith('write'), now, lookedAt: this.pullsLookedAt, log: this.log }), ...await mergedByAncestry(this, { reader: readerWith('read'), now, patchesIn: (repo, base, head) => this.patchesIn(repo, base, head) })]
1185
- return marked.length ? this.trimMergedBranches() : []
1186
- } catch (err) {
1187
- this.log(`[${this.name}] could not look for merged branches on GitHub: ${err.message}`)
1188
- return []
1189
- } finally { this.findingMerges = false }
1190
- }
1191
-
1192
- /**
1193
- * Branches whose work is merged leave the session (relay-ask.js mergedBranches): never the
1194
- * default branch or main, nor one someone is on or wrote on since the merge. Run when a pull
1195
- * request merges, on the relay's sweep, and before a new branch is refused at MAX_BRANCHES,
1196
- * so finished task branches never fill the session. Git keeps them. Returns the keys removed.
1197
- */
1198
- trimMergedBranches () {
1199
- if (this.destroyed || this.ended || !this.store) return []
1200
- let gone
1201
- try { gone = mergedBranches(this) } catch (err) { this.log(`[${this.name}] could not look for merged branches: ${err.message}`); return [] }
1202
- if (!gone.length) return []
1203
- const ups = this.meta.upstreams || {}
1204
- // Archived like an idle branch: an edit made after the merge from before the relay kept `changed` is not lost.
1205
- for (const k of gone) this.removeBranch(k, { save: false, archive: true })
1206
- this.log(`[${this.name}] removed ${gone.length} merged branch(es): ${gone.join(', ')}`)
1207
- this.saveMeta()
1208
- this.broadcastBranches()
1209
- if (this.postChat) this.postChat({ by: RELAY_BY, text: pruneNotice({ merged: gone }), kind: 'commit' })
1210
- // Their work is in the branch they merged into: their branches on GitHub go too (not one with an open pull request).
1211
- for (const k of gone) {
1212
- const rec = ups[k]
1213
- const repo = rec && (rec.repo || (rec.merged && rec.merged.repo))
1214
- const ref = rec && (rec.ref || (rec.merged && rec.merged.ref))
1215
- if (repo && ref && !rec.gone) {
1216
- deleteOnGithub(this, repo, ref, { fetch: this.githubFetch, log: this.log }).then((ok) => {
1217
- if (!ok) return
1218
- rec.gone = Date.now()
1219
- this.log(`[${this.name}] deleted merged branch ${ref} on GitHub`)
1220
- this.saveMeta()
1221
- })
1222
- }
1223
- }
1224
- return gone
1225
- }
1226
-
1227
1026
  /**
1228
1027
  * The branch with the most members on it (connected apps, and hosted agents that chose
1229
1028
  * one); ties go to the owner's, then the default. Hosted agents and chat links work here
@@ -1275,32 +1074,14 @@ class Room {
1275
1074
  * `create`: a new branch in the session, started from a copy of the files on the branch it
1276
1075
  * is on (as `git switch -c` carries a folder's work). Returns { branch, created, from }.
1277
1076
  */
1278
- setHostedBranch (id, key, { create = false, by = '', editor = true, github = null } = {}) {
1077
+ setHostedBranch (id, key, { create = false, by = '', editor = true } = {}) {
1279
1078
  if (!validBranchKey(key) || key === DEFAULT_KEY) throw new Error(`"${String(key).slice(0, 60)}" isn't a branch name.`)
1280
1079
  const to = this.resolveKey(key)
1281
1080
  const from = this.hostedBranch(id)
1282
1081
  const exists = !!this.meta.branches[to]
1283
- if (!exists && !create) throw new Error(`${key} isn't in this session. Pass create: true to start it: from GitHub when the branch is there, otherwise from a copy of the files on ${from}.`)
1082
+ if (!exists && !create) throw new Error(`${key} isn't in this session. Pass create: true to start it from a copy of the files on ${from}.`)
1284
1083
  if (exists && create) throw new Error(`${key} is already in this session: switch to it without create.`)
1285
- if (!exists && github) {
1286
- // The branch as it is on GitHub (branchFromGitHub): its files at its head, and the commit
1287
- // they are, so the relay brings later commits on it in (relay-upstream.js). Read through the
1288
- // relay's mirror (github.diff): nothing to copy, its files follow git at that commit.
1289
- this.assertMigrated()
1290
- this.assertBranchRoom(editor, github.bytes || 0)
1291
- const e = this.branchDoc(to, { by, base: null, editor })
1292
- if (github.diff) this.startDiff(to, e, github.upstream.sha)
1293
- else {
1294
- e.doc.transact(() => {
1295
- for (const [rel, t] of github.texts) { const y = new Y.Text(); y.insert(0, t); e.files.set(rel, y) }
1296
- for (const [rel, b] of github.blobs) e.blobs.set(rel, b)
1297
- }, 'hosted-branch')
1298
- }
1299
- upstreamsOf(this)[to] = { ...github.upstream, head: github.upstream.sha, checkedAt: Date.now(), hostedAt: Date.now() }
1300
- if (github.diff) syncStateOf(this, to).base = github.upstream.sha
1301
- } else if (!exists && this.diffForHosted() && this.cutAsDiff(id, to, from, { by, editor })) {
1302
- // Cut as a diff branch: only what `from` changed relative to git came along (cutAsDiff).
1303
- } else if (!exists) {
1084
+ if (!exists) { } else if (!exists) {
1304
1085
  this.assertMigrated()
1305
1086
  this.assertBranchRoom(editor, this.store.size(from)) // a copy of `from`'s files
1306
1087
  const src = this.branchDoc(from, { by })
@@ -1311,7 +1092,6 @@ class Room {
1311
1092
  for (const [k, v] of src.fileKeys) e.fileKeys.set(k, v)
1312
1093
  for (const [k, v] of src.relayKeys) e.relayKeys.set(k, v)
1313
1094
  }, 'hosted-branch')
1314
- this.noteBranchCut(to, from)
1315
1095
  }
1316
1096
  this.meta.hostedBranch = { ...(this.meta.hostedBranch || {}), [id]: to }
1317
1097
  if (this.meta.remoteBranches && this.meta.remoteBranches[to]) delete this.meta.remoteBranches[to]
@@ -1320,83 +1100,6 @@ class Room {
1320
1100
  return { branch: to, created: !exists, from }
1321
1101
  }
1322
1102
 
1323
- /**
1324
- * GitHub as a node: a branch cut in the session from a live-synced branch is mirrored to the
1325
- * GitHub branch of the same name (plan §0, §2): its record follows the same repository, and it
1326
- * is measured from where it was cut, so its first push creates it on GitHub on that commit.
1327
- */
1328
- noteBranchCut (to, from) {
1329
- if (!this.cfg.gitSync || !this.mirrors) return false
1330
- const recs = upstreamsOf(this)
1331
- const src = recs[from]
1332
- if (!src || !src.repo || !src.ref || recs[to]) return false
1333
- const base = (this.meta.sync && this.meta.sync[from] && this.meta.sync[from].base) || src.sha
1334
- if (!base) return false
1335
- const remote = src.name && src.name.endsWith(`/${src.ref}`) ? src.name.slice(0, -(src.ref.length + 1)) : 'origin'
1336
- recs[to] = { repo: src.repo, name: `${remote}/${to}`, ref: to, sha: base, head: null, cutFrom: from, cutAt: base, checkedAt: 0, ...(src.github ? { github: { ...src.github } } : {}) }
1337
- syncStateOf(this, to).base = base
1338
- this.saveMeta()
1339
- return true
1340
- }
1341
-
1342
- /**
1343
- * A hosted agent's new branch `to`, cut from `from` as a diff branch (branch-view.js cutOf): the
1344
- * files `from` changed relative to git, its deletions, and where each started. False (nothing
1345
- * made) when it has to be a full copy: git's tree at `from`'s base isn't read yet.
1346
- */
1347
- cutAsDiff (id, to, from, { by = '', editor = true } = {}) {
1348
- this.assertMigrated()
1349
- const src = this.branchDoc(from, { by })
1350
- const cut = cutOf(this, from, src)
1351
- if (!cut) return false
1352
- this.assertBranchRoom(editor, cut.bytes)
1353
- const e = this.branchDoc(to, { by, base: this.meta.branches[from] ? this.meta.branches[from].base : null, editor })
1354
- this.startDiff(to, e, cut.base)
1355
- e.doc.transact(() => {
1356
- for (const rel of cut.paths) {
1357
- const t = src.files.get(rel)
1358
- if (t) { const y = new Y.Text(); y.insert(0, t.toString()); e.files.set(rel, y) } else e.blobs.set(rel, src.blobs.get(rel))
1359
- if (src.modes.has(rel)) e.modes.set(rel, src.modes.get(rel))
1360
- notePresent(e, rel, { base: cut.fileBases.get(rel) })
1361
- }
1362
- for (const rel of cut.gone) removePath(e, rel, { by, base: cut.fileBases.get(rel) })
1363
- for (const [k, v] of src.fileKeys) e.fileKeys.set(k, v)
1364
- for (const [k, v] of src.relayKeys) e.relayKeys.set(k, v)
1365
- }, 'hosted-branch')
1366
- this.noteBranchCut(to, from)
1367
- return true
1368
- }
1369
-
1370
- /** Before a hosted agent cuts a branch from the one it is on: git's tree there is read, so the cut can be a diff branch. Never throws. */
1371
- async readyToCut (id) {
1372
- if (!this.diffForHosted()) return
1373
- const from = this.hostedBranch(id)
1374
- const e = this.store.get(from) || this.branchDoc(from)
1375
- try { await gitOf(this, from, e) } catch {}
1376
- }
1377
-
1378
- /**
1379
- * Whether every commit of `head` not in `base` is already in `base` as the same change (`git cherry`
1380
- * in the relay's mirror of `repo`: a branch cherry-picked or rebased into its base). False when the
1381
- * mirror can't tell (no mirror, a commit it can't get).
1382
- */
1383
- async patchesIn (repo, base, head) {
1384
- if (!this.mirrors || !this.cfg.gitSync) return false
1385
- const r = parseRepo(`https://${repo}`)
1386
- if (!r || !r.github) return false
1387
- const token = await this.gitToken(r)
1388
- const { dir } = await this.mirrors.ensure(r, { url: this.gitRemoteUrl(r) })
1389
- for (const sha of [base, head]) if (!(await ensureCommit(dir, sha, { token }))) return false
1390
- return samePatches(dir, base, head)
1391
- }
1392
-
1393
- /** Reads git's tree at branch `key`'s base into the cache (a cut from it can then be a diff branch). Never throws. */
1394
- warmTree (key) {
1395
- const e = this.store.get(key)
1396
- if (!e || this.destroyed) return
1397
- gitOf(this, key, e).catch(() => {})
1398
- }
1399
-
1400
1103
  /** A hosted agent's branch choice, forgotten once its membership here ends (it left, or the owner removed it). */
1401
1104
  forgetHostedBranch (id, { save = true } = {}) {
1402
1105
  if (!this.meta.hostedBranch || !(id in this.meta.hostedBranch)) return
@@ -1415,9 +1118,9 @@ class Room {
1415
1118
  assertBranchRoom (editor = true, adding = 0) {
1416
1119
  if (this.unsavable) throw new Error("The relay can't save this session right now, so it can't take another branch.")
1417
1120
  // Merged and week-idle branches make room first (relay-prune.js), then the limits are checked.
1418
- if (this.totalBytes() + adding > this.cfg.maxRoomBytes * MAX_TOTAL_FACTOR) { this.trimMergedBranches(); this.pruneBranches() }
1121
+ if (this.totalBytes() + adding > this.cfg.maxRoomBytes * MAX_TOTAL_FACTOR) this.pruneBranches()
1419
1122
  if (this.totalBytes() + adding > this.cfg.maxRoomBytes * MAX_TOTAL_FACTOR) throw new Error("This session is at its size limit across all its branches, so it can't take another branch. Merged branches, and branches nobody worked on for a week, leave the session by themselves.")
1420
- if (Object.keys(this.meta.branches).length >= MAX_BRANCHES) { this.trimMergedBranches(); this.pruneBranches() } // merged and idle branches make room first
1123
+ if (Object.keys(this.meta.branches).length >= MAX_BRANCHES) this.pruneBranches() // idle branches make room first
1421
1124
  if (Object.keys(this.meta.branches).length >= MAX_BRANCHES) throw new Error(`This session already has ${MAX_BRANCHES} branches, its limit; an existing one has to go before another can start.`)
1422
1125
  if (!editor) throw new Error('you can only view this session, so you cannot start a new branch')
1423
1126
  }
@@ -1435,424 +1138,12 @@ class Room {
1435
1138
  const k = this.hostedBranch(h.id)
1436
1139
  hosted.set(k, [...(hosted.get(k) || []), h.name])
1437
1140
  }
1438
- const ups = this.meta.upstreams || {}
1439
- const list = Object.entries(this.meta.branches).map(([key, b]) => {
1440
- const relay0 = relayEntry(ups[key])
1441
- const live = relay0 && this.cfg.gitSync && this.mirrors ? liveSyncOn(this, key) : false
1442
- const sync = relay0 && this.cfg.gitSync && this.mirrors ? syncStatus(this, key, { live }) : null
1443
- const st = this.meta.sync && this.meta.sync[key]
1444
- // `git`: this relay serves the session's branches over git (relay-githttp.js): agents' folders use it as their origin.
1445
- const relay = relay0 ? { ...relay0, ...(sync ? { sync } : {}), ...(live ? { live: true } : {}), ...(this.cfg.gitSync && this.cfg.gitHttp !== false && this.cfg.passPublicKey && this.mirrors && this.dataDirOf && /^github\.com\//.test(String(relay0.repo || '')) ? { git: true } : {}), ...(live && this.meta.largeToRelay === true && this.relayFileKey ? { fileKey: this.relayFileKey.publicKey } : {}), ...(live && st && st.protected && !st.protected.leased ? { protected: true } : {}), ...(live && st && st.protected && st.protected.leased ? { leased: true, ...(this.sameOwner(ups[key].leasedBy) ? { liveIn: ups[key].leasedBy } : {}) } : {}), ...(st && st.pr && st.pr.number ? { pr: { number: st.pr.number, url: st.pr.url || '' } } : {}) } : null
1446
- const merged = ups[key] && ups[key].merged ? { into: ups[key].merged.into, number: ups[key].merged.number, at: ups[key].merged.at } : null
1447
- return { key, by: b.by || '', at: b.at || 0, base: b.base || null, default: key === this.defaultKey, hosted: hosted.get(key) || [], ...(this.branchFull(key) ? { full: true } : {}), ...(relay ? { relay } : {}), ...(merged ? { merged } : {}) }
1448
- })
1449
- // Branches on GitHub the session doesn't have yet (a create webhook, §5.4): in the menu, synced once someone opens them.
1450
- for (const [key, r] of Object.entries(this.meta.remoteBranches || {})) if (!this.meta.branches[key]) list.push({ key, by: '', at: (r && r.at) || 0, base: null, default: false, hosted: [], onGitHub: true })
1451
- return list
1452
- }
1453
-
1454
- // ------------------------------------------------------- upstream (relay-upstream.js) --
1455
- // Commits pushed to GitHub come into a branch through a member's folder. A branch only hosted
1456
- // agents are working on, with no folder online, is brought up to date by the relay instead.
1457
-
1458
- /** Members' presence says which upstream each branch follows, and the commit its folders are at. */
1459
- noteUpstreams () {
1460
- if (!this.exists || this.ended || this.destroyed) return
1461
- noteHolds(this)
1462
- const was = new Map(Object.entries(upstreamsOf(this)).map(([key, rec]) => [key, rec && rec.sha]))
1463
- if (noteFolders(this, this.log)) {
1464
- this.saveMeta(); this.broadcastBranches(); this.noteConnections()
1465
- // A folder brought GitHub's commits in itself (an older app): their files are marked as from GitHub too.
1466
- if (this.cfg.gitSync && this.mirrors) {
1467
- for (const [key, rec] of Object.entries(upstreamsOf(this))) {
1468
- const from = was.get(key)
1469
- if (rec && rec.sha && from && rec.sha !== from && rec.relayHead !== rec.sha) markFolderBrought(this, key, from, rec.sha, { log: this.log })
1470
- }
1471
- }
1472
- }
1473
- // With the mirror on, a branch due a look (at start, after a member connects, or when the backstop is up) is looked at as soon as someone is on it.
1474
- if (this.cfg.gitSync && this.mirrors) {
1475
- for (const ws of this.conns.keys()) if (ws.branch && !this.lookedFor.has(ws)) { this.lookedFor.add(ws); const rec = upstreamsOf(this)[ws.branch]; if (rec) { rec.checkedAt = 0; delete rec.webhookAt } }
1476
- this.lookSoon()
1477
- }
1478
- }
1479
-
1480
- /**
1481
- * What agents may commit, when the session's org or workspace sets it for all its sessions (the accounts API's
1482
- * /v1/relay/session-policy): kept in meta.agentCommitsSet ({ mode, by, name }) and put before the owner's own.
1483
- * Asked when the room loads, about every ten minutes, and at once when the API says it changed (/github/policy).
1484
- */
1485
- async refreshPolicy () {
1486
- if (!this.cfg.apiUrl || !this.cfg.relayApiSecret || this.destroyed || !this.exists) return
1487
- this.policyAt = Date.now()
1488
- try {
1489
- const res = await (this.apiFetch || globalThis.fetch)(`${String(this.cfg.apiUrl).replace(/\/+$/, '')}/v1/relay/session-policy`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${this.cfg.relayApiSecret}` }, body: JSON.stringify({ room: this.name }) })
1490
- if (!res.ok || this.destroyed) return
1491
- const b = await res.json()
1492
- const next = AGENT_COMMITS.includes(b.agentCommits) ? { mode: b.agentCommits, by: ['org', 'team'].includes(b.by) ? b.by : 'workspace', name: String(b.name || '').slice(0, 100) } : null
1493
- if (JSON.stringify(next) === JSON.stringify(this.meta.agentCommitsSet || null)) return
1494
- const was = agentCommitsOf(this)
1495
- if (next) this.meta.agentCommitsSet = next; else delete this.meta.agentCommitsSet
1496
- this.saveMeta()
1497
- if (agentCommitsOf(this) === 'off' && was !== 'off') this.revokeAgentTokens({ access: 'write' })
1498
- this.broadcastAccess()
1499
- this.log(`[${this.name}] what agents may commit: ${next ? `${next.mode}, set by the ${next.by}${next.name ? ` ${next.name}` : ''}` : 'the session owner decides'}`)
1500
- } catch (err) { this.log(`[${this.name}] could not ask the accounts API what agents may commit: ${err.message}`) }
1501
- }
1502
-
1503
- /** Each branch that follows a GitHub repository learns whether a member's connection lets agents commit there (relay-upstream.js noteConnection). */
1504
- noteConnections ({ keys = null, force = false } = {}) {
1505
- if (force || Date.now() - (this.policyAt || 0) > POLICY_EVERY_MS) this.refreshPolicy()
1506
- // Which GitHub account each person linked, for commit authorship (relay-github.js); about once an hour.
1507
- if (this.cfg.gitSync && this.mirrors) noteMemberLogins(this, { force, fetch: this.apiFetch }).catch(() => {})
1508
- for (const [key, rec] of Object.entries(upstreamsOf(this))) {
1509
- if (!rec || !rec.repo || (keys && !keys.includes(key))) continue
1510
- // A "yes" stands an hour; a "no" is asked again every ten minutes (someone may have connected
1511
- // from an app that couldn't say so), unless forced.
1512
- if (!force && rec.github && Date.now() - (rec.github.at || 0) < (rec.github.state === 'ok' ? 60 : 10) * 60 * 1000) continue
1513
- noteConnection(this, key, { force }).then((changed) => { if (changed && !this.destroyed) { this.saveMeta(); this.broadcastBranches(); this.broadcastAccess() } }).catch((err) => this.log(`[${this.name}] ${key}: could not check the GitHub connection: ${err.message}`))
1514
- }
1515
- }
1516
-
1517
- /** A hosted agent just called a tool: its branch counts as worked on (kept, so it outlasts the room leaving memory), and is looked at if due. */
1518
- noteHostedUpstream (id) {
1519
- const key = this.meta.layout === 2 ? this.hostedBranch(id) : null
1520
- const rec = key && (this.meta.upstreams || {})[key]
1521
- if (rec && Date.now() - (rec.hostedAt || 0) >= 60 * 1000) { rec.hostedAt = Date.now(); this.saveMeta() }
1522
- this.lookSoon()
1523
- }
1524
-
1525
- /** The relay's next look at the branches due (not forced), as soon as the current turn is over. */
1526
- lookSoon () {
1527
- if (!this.cfg.upstreamAuto || this.upstreamSoonTimer) return
1528
- this.upstreamSoonTimer = setTimeout(() => {
1529
- this.upstreamSoonTimer = null
1530
- this.checkUpstreams().catch((err) => this.log(`[${this.name}] could not look for new commits: ${err.message}`))
1531
- }, 0)
1532
- this.upstreamSoonTimer.unref?.()
1533
- }
1534
-
1535
- /**
1536
- * Looks at GitHub for the branches due (or, with `force`, every branch the relay looks after,
1537
- * except those GitHub told to wait), one at a time; only `keys` when given. One run at a time:
1538
- * an ask meanwhile gets that run.
1539
- */
1540
- checkUpstreams ({ force = false, keys = null } = {}) {
1541
- // A look asked for (force) waits for one under way, then has its own: the one under way may not include its branches.
1542
- if (this.upstreamRun) return force ? this.upstreamRun.catch(() => {}).then(() => this.checkUpstreams({ force, keys })) : this.upstreamRun
1543
- this.checkForcedDate()
1544
- if (!this.exists || this.ended || this.destroyed || this.meta.layout !== 2 || this.meta.syncStopped) return Promise.resolve([])
1545
- this.noteConnections({ keys }) // the connections, about once an hour per branch, with the relay's own looks
1546
- const now = Date.now()
1547
- // With the mirror on: about every minute (git ls-remote, no API quota); webhooks in between (addendum 16.13).
1548
- const live = this.cfg.gitSync && this.mirrors
1549
- const activeMs = Number.isFinite(this.cfg.activeBackstopMs) ? this.cfg.activeBackstopMs : ACTIVE_BACKSTOP_MS
1550
- const due = live && !force
1551
- ? [...new Set([...dueBranches(this, { now, everyMs: activeMs, keys }).filter((k) => relayLooksActive(this, k)), ...dueBranches(this, { now, everyMs: BACKSTOP_MS, keys })])]
1552
- : dueBranches(this, { now, everyMs: force ? 0 : this.cfg.upstreamCheckMs, keys })
1553
- if (!due.length) return Promise.resolve([])
1554
- this.upstreamRun = (async () => {
1555
- const out = []
1556
- for (const key of due) {
1557
- if (this.destroyed || !relayLooks(this, key)) continue
1558
- try {
1559
- // Through the relay's git mirror (GitHub as a node), or the older REST path.
1560
- const r = this.cfg.gitSync && this.mirrors ? await bringInBranch(this, key, { log: this.log }) : await checkBranch(this, key, { fetch: this.githubFetch, log: this.log, fallbackToken: this.cfg.githubToken })
1561
- out.push({ branch: key, ...r })
1562
- // GitHub out of reach for a look: the branch says so (offline), until a look gets through.
1563
- const sync = this.cfg.gitSync && this.meta.sync && this.meta.sync[key]
1564
- if (sync && r.unreachable && !sync.paused) {
1565
- sync.paused = { why: 'unreachable', at: Date.now(), text: 'a look at GitHub failed', backoffMs: 0, until: 0 }
1566
- const rec = upstreamsOf(this)[key]
1567
- if (rec) rec.problem = `GitHub unreachable since ${new Date(sync.paused.at).toISOString().slice(11, 16)} UTC: the session keeps working, and GitHub's changes come in when it answers`
1568
- this.saveMeta(); this.broadcastBranches()
1569
- }
1570
- // GitHub answered: an outage is over. Commits asked for meanwhile go now; otherwise the branch just says it is reachable again.
1571
- if (sync && sync.paused && sync.paused.why === 'unreachable' && ['brought', 'up-to-date', 'recovered', 'rewrite'].includes(r.state)) {
1572
- if (Array.isArray(sync.asked) && sync.asked.length) this.outbound.recover(key).catch((err) => this.log(`[${this.name}] ${key}: could not make the commits asked for: ${err.message}`))
1573
- else {
1574
- delete sync.paused
1575
- const rec = upstreamsOf(this)[key]
1576
- if (rec && /^GitHub unreachable since/.test(rec.problem || '')) delete rec.problem
1577
- this.saveMeta(); this.broadcastBranches()
1578
- }
1579
- }
1580
- } catch (err) {
1581
- this.log(`[${this.name}] ${key}: could not look for new commits: ${err.message}`)
1582
- out.push({ branch: key, state: 'error' })
1583
- }
1584
- }
1585
- return out
1586
- })().finally(() => { this.upstreamRun = null })
1587
- return this.upstreamRun
1588
- }
1589
-
1590
- /** Looks at branches `keys` now, after any look under way (not the one under way: it may not include them). */
1591
- async checkUpstreamsNow (keys) {
1592
- while (this.upstreamRun) { try { await this.upstreamRun } catch {} }
1593
- return this.checkUpstreams({ force: true, keys })
1594
- }
1595
-
1596
- /** The claims held for clash task `id` (and its holder's requests for its files) let go of: the merge is done. */
1597
- dropClashClaims (id) {
1598
- const held = Object.values(this.meta.claims || {}).some((c) => c.clash === id || (c.queue || []).some((r) => r.clash === id))
1599
- if (!held) return
1600
- this.dropRequests((r) => r.clash === id)
1601
- this.dropClaims((c) => c.clash === id, (c) => `the merge ${c.by} held it for (task ${id}) is done.`)
1602
- this.broadcastClaims()
1603
- }
1604
-
1605
- /**
1606
- * Claims the files of clash task `id` for its assignee `who` (a claimant: { name, id, talk, branch })
1607
- * on that branch, taking them over from whoever held them for the task before. A file someone else
1608
- * holds is asked for in its file queue instead. Returns { claimed: [path], asked: [{ path, holder }] }.
1609
- */
1610
- claimClashFiles (who, id, paths, { upstream = 'upstream' } = {}) {
1611
- const claimed = []
1612
- const asked = []
1613
- for (const rel of paths) {
1614
- try {
1615
- this.claimRequest(who, { op: 'claim', pattern: rel, note: `Merging commits from ${upstream} (task ${id})`, clash: id })
1616
- claimed.push(rel)
1617
- } catch {
1618
- const c = this.claimFor(rel, who.branch)
1619
- if (!c) continue
1620
- try {
1621
- this.claimRequest(who, { op: 'request', path: rel, title: `Merging commits from ${upstream}`, description: `Task ${id}: the commits clash with the session's work in this file. I merge it once you hand it over.`, task: id, clash: id })
1622
- asked.push({ path: rel, holder: c.by })
1623
- } catch {}
1624
- }
1625
- }
1626
- if (claimed.length || asked.length) this.broadcastClaims()
1627
- return { claimed, asked }
1628
- }
1629
-
1630
- /** Branch `key` is looked at again at once, a moment from now (several asks make one look). */
1631
- upstreamSoon (key) {
1632
- if (!this.cfg.upstreamOnChange || !key) return
1633
- this.upstreamSoonKeys.add(key)
1634
- if (this.upstreamAgainTimer) return
1635
- this.upstreamAgainTimer = setTimeout(() => {
1636
- this.upstreamAgainTimer = null
1637
- const keys = [...this.upstreamSoonKeys]
1638
- this.upstreamSoonKeys.clear()
1639
- this.checkUpstreamsNow(keys).catch((err) => this.log(`[${this.name}] could not look for new commits: ${err.message}`))
1640
- }, 50)
1641
- this.upstreamAgainTimer.unref?.()
1642
- }
1643
-
1644
- /** A file on branch `key` changed by a member: when it is in the relay's open clash there, the relay looks again at once. */
1645
- noteClashWrite (key, rel) {
1646
- const rec = (this.meta.upstreams || {})[key]
1647
- if (rec && rec.clash && rec.clash.files && rel in rec.clash.files) this.upstreamSoon(key)
1141
+ return Object.entries(this.meta.branches).map(([key, b]) => ({ key, by: b.by || '', at: b.at || 0, base: b.base || null, default: key === this.defaultKey, hosted: hosted.get(key) || [], ...(this.branchFull(key) ? { full: true } : {}) }))
1648
1142
  }
1649
1143
 
1650
- /**
1651
- * A task moved to QA or Done: when it is a relay clash's and its assignee moved it, the files it
1652
- * lists count as merged as they are (for that commit), and the relay looks again at once.
1653
- * Who moved it: the connection's member (or one of its AI sessions), or the hosted agent whose tool ran.
1654
- */
1655
- noteClashMoved (id, task, origin) {
1656
- const recs = this.meta.upstreams || {}
1657
- const key = Object.keys(recs).find((k) => recs[k] && recs[k].clash && recs[k].clash.task === id)
1658
- if (!key || !task.assignee) return
1659
- const names = []
1660
- if (origin && this.conns.has(origin)) {
1661
- names.push(this.names.get(origin))
1662
- const states = this.awareness.getStates()
1663
- for (const cid of this.conns.get(origin)) for (const p of (states.get(cid)?.personas || [])) if (p && p.name) names.push(p.name)
1664
- } else if (this.auditAs && this.meta.members[this.auditAs]) names.push(this.meta.members[this.auditAs].name)
1665
- if (!names.includes(task.assignee)) return
1666
- const rec = recs[key]
1667
- rec.clash.settled = { by: task.assignee, at: Date.now() }
1668
- this.saveMeta()
1669
- this.log(`[${this.name}] ${key}: ${task.assignee} moved the clash task ${id} to ${task.column}: its files count as merged`)
1670
- this.upstreamSoon(key)
1671
- }
1672
-
1673
- /**
1674
- * quilt_sync_branch where no folder can do it: the relay asks GitHub about branch `key` now
1675
- * (at most once a minute per branch) and brings commits in as on its own looks.
1676
- * Returns { state, said } (said: for the AI that asked).
1677
- */
1678
- async syncUpstreamNow (key) {
1679
- key = this.resolveKey ? this.resolveKey(key) : key
1680
- const rec = upstreamsOf(this)[key]
1681
- if (!rec || !rec.repo || !rec.ref) return { state: 'unknown-repo', said: `The relay doesn't know which GitHub repository \`${key}\` follows yet: a member's folder on it (a git clone) tells it once it is online.` }
1682
- if (!rec.sha) return { state: 'unknown-commit', said: `The relay doesn't know which commit of ${rec.name || 'the upstream'} the session's files on \`${key}\` are at yet: a member's folder on it, in step with the session, tells it.` }
1683
- // With the mirror on, folders follow the relay's commits: only an older app's folder brings them in itself.
1684
- const folder = this.cfg.gitSync && this.mirrors ? catcherAble(this, key) : folderAble(this, key)
1685
- if (folder) return { state: 'folder', said: `${folder}'s folder on \`${key}\` brings commits in itself (it fetches ${rec.name} about once a minute), so the relay leaves it to that folder.` }
1686
- if (!relayLooks(this, key)) return { state: 'not-looking', said: `The relay isn't looking after \`${key}\` right now.` }
1687
- const now = Date.now()
1688
- const last = this.syncAsked.get(key) || 0
1689
- // Through the mirror a look is a git ls-remote, no API quota: folders waiting for a commit ask too (session.js bringIn).
1690
- const mirror = this.cfg.gitSync && this.mirrors
1691
- if (now - last < (mirror ? SYNC_EVERY_GIT_MS : SYNC_EVERY_MS)) {
1692
- const ago = Math.max(1, Math.round((now - (rec.checkedAt || last)) / 1000))
1693
- return { state: 'recent', said: `The relay asked GitHub about ${rec.name} for \`${key}\` ${ago}s ago, and asks at most once ${mirror ? `every ${SYNC_EVERY_GIT_MS / 1000} seconds` : 'a minute'} per branch. ${rec.clash ? `Its clash is task ${rec.clash.task} on the board.` : rec.problem ? `Note: ${rec.problem}.` : rec.sha === rec.head ? `\`${key}\` was up to date with ${rec.name} then.` : ''}`.trim() }
1694
- }
1695
- this.syncAsked.set(key, now)
1696
- if (rec.backoffUntil > now) return { state: 'backoff', said: `Not now: ${rec.problem || 'GitHub asked the relay to slow down'}.` }
1697
- const out = await this.checkUpstreamsNow([key])
1698
- const r = out.find((x) => x.branch === key)
1699
- return { state: r ? r.state : 'not-looking', said: describeRelayCheck(r, upstreamsOf(this)[key] || rec, key, this) }
1700
- }
1701
-
1702
- /**
1703
- * A member's commit to GitHub from the session's copy of the files on branch `key`
1704
- * (relay-commit.js): who is { name, kind, access }; `req` says files, message, branch,
1705
- * pullRequest, withOthers, task. One at a time per session.
1706
- */
1707
- async commitFor (who, key, req = {}) {
1708
- const run = () => commitForMember(this, {
1709
- who,
1710
- key,
1711
- files: Array.isArray(req.files) ? req.files.slice(0, 1000).map(String) : null,
1712
- message: typeof req.message === 'string' ? req.message : '',
1713
- branch: typeof req.branch === 'string' ? req.branch : '',
1714
- pullRequest: req.pullRequest === true,
1715
- withOthers: req.withOthers === true,
1716
- task: typeof req.task === 'string' ? req.task : null
1717
- }, { fetch: this.githubFetch, log: this.log })
1718
- const next = (this.committing || Promise.resolve()).then(run, run)
1719
- this.committing = next.catch(() => {})
1720
- return next
1721
- }
1722
-
1723
- /**
1724
- * A token for the repository branch `key` follows, for a member's folder (relay-github.js): read,
1725
- * to fetch or clone; write (`access`), to push with, while git is on for everyone here (git for
1726
- * everyone: agentCommitsOf isn't 'off'; an agent's pre-push hook holds its pushes to the policy's
1727
- * branches). An agent's or a person's write token is their own (minted for them alone, `actingFor`
1728
- * their account, so the org's Require GitHub and the workspace's repositories apply as for anyone),
1729
- * kept here so it can be taken back at GitHub when they are removed or git turns off. `who`:
1730
- * { name, kind, id } (id: their account, person:<id>). { token, expiresAt, repo, access } or
1731
- * { state } when there is none. A token the owner pasted by hand is never handed out.
1732
- */
1733
- async gitTokenFor (key, { access = 'read', who = null, repo: asked = null } = {}) {
1734
- key = this.resolveKey(key)
1735
- // The repository: the one the folder's git names (`asked`, owner/name), when a branch here follows
1736
- // it; else the branch's own record, else the default branch's (a new feature branch has no record yet).
1737
- let rec = null
1738
- if (typeof asked === 'string' && /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(asked)) {
1739
- rec = Object.values(upstreamsOf(this)).find((r) => r && r.repo && String(r.repo).toLowerCase() === `github.com/${asked}`.toLowerCase()) || null
1740
- }
1741
- if (!rec) rec = (repoRecordFor(this, key) || {}).rec || null
1742
- const repo = rec && rec.repo ? parseRepo(`https://${rec.repo}`) : null
1743
- if (!repo || !repo.github) return { state: 'not-github' }
1744
- const full = `${repo.owner}/${repo.name}`
1745
- const agent = !!who && who.kind === 'agent' && !!who.name
1746
- const person = !!who && who.kind !== 'agent' && /^person:[A-Za-z0-9_-]{1,64}$/.test(who.id || '')
1747
- const on = agentCommitsOf(this) !== 'off'
1748
- const level = access === 'write' && on && (agent || person) ? 'write' : 'read'
1749
- // A person reading: the relay's own read token, as before. Everyone writing, and agents always, get one of their own.
1750
- if (!agent && level === 'read') {
1751
- const r = await appCredentials(this, full, { access: level })
1752
- return r.token ? { token: r.token, expiresAt: r.expiresAt, repo: full, access: level } : { state: r.state }
1753
- }
1754
- if (!this.agentTokens) this.agentTokens = new Map()
1755
- const k = `${agent ? who.name : who.id}\0${full}\0${level}`.toLowerCase()
1756
- const hit = this.agentTokens.get(k)
1757
- if (hit && hit.expiresAt - Date.now() > 5 * 60 * 1000) return { token: hit.token, expiresAt: hit.expiresAt, repo: full, access: level }
1758
- // Who the token is for decides when the org requires GitHub (plan §2): an agent's member key is agent:<id>, a person's person:<id>.
1759
- const actor = agent ? (Object.entries(this.meta.members || {}).find(([k, m]) => k.startsWith('agent:') && m && m.name === who.name) || [])[0] : who.id
1760
- const r = await appCredentials(this, full, { access: level, share: false, ...(actor ? { actingFor: actor } : {}) })
1761
- if (!r.token) return { state: r.state, ...(r.why ? { why: r.why } : {}), ...(r.require ? { require: true } : {}) }
1762
- this.agentTokens.set(k, { token: r.token, expiresAt: r.expiresAt, name: who.name, account: agent ? actor || '' : who.id, access: level })
1763
- return { token: r.token, expiresAt: r.expiresAt, repo: full, access: level }
1764
- }
1765
-
1766
- /** Takes back the tokens handed to members' folders: one agent's (`name`), one account's (`account`), or every write token (`access`). */
1767
- revokeAgentTokens ({ name = null, account = null, access = null } = {}) {
1768
- if (!this.agentTokens) return
1769
- for (const [k, t] of this.agentTokens) {
1770
- if ((name && t.name !== name) || (account && t.account !== account) || (access && t.access !== access)) continue
1771
- this.agentTokens.delete(k)
1772
- revokeGitHubToken(t.token, { fetch: this.githubFetch }).then((ok) => { if (ok) this.log(`[${this.name}] took back ${t.name}'s ${t.access} token for GitHub`) })
1773
- }
1774
- }
1775
-
1776
- /** Everyone's access again: what agents may do with commits, and whether they can (the GitHub connection) changed. */
1144
+ /** Everyone's access again. */
1777
1145
  broadcastAccess () { for (const [cws, a] of this.access) send(cws, jsonMessage(MSG_ACCESS, this.accessMessage(a))) }
1778
1146
 
1779
- /**
1780
- * Git for this session, the owner's call ('off', 'branches', 'any'; '' follows the rule over the session,
1781
- * or the default): within the rule over it (an org's, a team's, a workspace's), which is a ceiling: the
1782
- * owner may be stricter, never looser. Everyone's access says it.
1783
- */
1784
- setAgentCommits (mode) {
1785
- if (mode !== '' && !AGENT_COMMITS.includes(mode)) throw new Error(`pick one of ${AGENT_COMMITS.join(', ')}`)
1786
- const set = this.meta.agentCommitsSet
1787
- if (mode && set && POLICY_RANK[mode] > POLICY_RANK[set.mode]) {
1788
- const what = set.mode === 'off' ? 'turned git off for its sessions' : 'lets agents push only to branches of their own in its sessions'
1789
- throw new Error(`${ruleSetter(set)} ${what}: change it in its settings`)
1790
- }
1791
- if ((mode || undefined) !== this.meta.agentCommits) {
1792
- const was = agentCommitsOf(this)
1793
- if (mode) this.meta.agentCommits = mode; else delete this.meta.agentCommits
1794
- this.saveMeta()
1795
- if (agentCommitsOf(this) === 'off' && was !== 'off') this.revokeAgentTokens({ access: 'write' }) // nobody's folder can push any more, at once
1796
- for (const [cws, a] of this.access) send(cws, jsonMessage(MSG_ACCESS, this.accessMessage(a)))
1797
- }
1798
- return agentCommitsOf(this)
1799
- }
1800
-
1801
- /** The owner sets (or clears, with '') the read-only GitHub token. Never echoed or logged. */
1802
- setGithubToken (token) {
1803
- const t = String(token ?? '').trim()
1804
- if (t && !validToken(t)) throw new Error("That doesn't look like a GitHub token (ghp_… or github_pat_…).")
1805
- if (t) this.meta.githubToken = t
1806
- else delete this.meta.githubToken
1807
- this.log(`[${this.name}] the owner ${t ? 'set' : 'cleared'} the GitHub token for bringing in commits`)
1808
- this.upstreamTokenChanged()
1809
- this.broadcastAccess() // the owner sees whether a token is set; everyone sees whether agents can commit now
1810
- return { ok: true, githubToken: !!t }
1811
- }
1812
-
1813
- /**
1814
- * A branch on GitHub a hosted agent switches to with create (quilt_switch_branch): its files at
1815
- * the branch's head, from the repository the session follows (the agent's branch's, else any
1816
- * branch's). { state: 'loaded', texts, blobs, skipped, bytes, upstream: { repo, name, ref, sha } }
1817
- * or { state, repo } saying why not ('no-repo', 'not-github', 'no-branch', 'private', ...).
1818
- */
1819
- async branchFromGitHub (id, key) {
1820
- const recs = upstreamsOf(this)
1821
- const mine = recs[this.hostedBranch(id)]
1822
- const src = mine && mine.repo ? mine : Object.values(recs).find((r) => r && r.repo)
1823
- if (!src) return { state: 'no-repo' }
1824
- const repo = parseRepo(`https://${src.repo}`)
1825
- if (!repo || !repo.github) return { state: 'not-github', repo: src.repo }
1826
- const remote0 = src.name && src.ref && src.name.endsWith(`/${src.ref}`) ? src.name.slice(0, -(src.ref.length + 1)) : 'origin'
1827
- if (this.diffForHosted()) {
1828
- // Through the relay's mirror: only the branch's head, since a diff branch's files follow git (branch-view.js).
1829
- try {
1830
- const token = await this.gitToken(repo)
1831
- const { dir } = await this.mirrors.ensure(repo, { url: this.gitRemoteUrl(repo) })
1832
- const { sha } = await fetchBranch(dir, key, { token })
1833
- if (!sha) return { state: 'no-branch', repo: src.repo }
1834
- return { state: 'loaded', diff: true, head: sha, texts: new Map(), blobs: new Map(), skipped: [], bytes: 0, repo: src.repo, upstream: { repo: src.repo, name: `${remote0}/${key}`, ref: key, sha } }
1835
- } catch (err) {
1836
- if (err.kind === 'auth') return { state: 'private', repo: src.repo }
1837
- return { state: 'error', repo: src.repo, message: String(err.message).slice(0, 160) }
1838
- }
1839
- }
1840
- const own = validToken(this.meta.githubToken) ? this.meta.githubToken : null
1841
- const token = own || (validToken(this.cfg.githubToken) ? this.cfg.githubToken : null)
1842
- const r0 = await loadBranch({ repo: src.repo, ref: key, token, fetch: this.githubFetch })
1843
- const r = r0.state === 'token-refused' && !own ? { ...r0, state: 'private' } : r0
1844
- const remote = src.name && src.ref && src.name.endsWith(`/${src.ref}`) ? src.name.slice(0, -(src.ref.length + 1)) : 'origin'
1845
- return { ...r, repo: src.repo, ...(r.state === 'loaded' ? { upstream: { repo: src.repo, name: `${remote}/${key}`, ref: key, sha: r.head } } : {}) }
1846
- }
1847
-
1848
- /** The owner set (or cleared) the read-only GitHub token: every branch is looked at afresh. */
1849
- upstreamTokenChanged () {
1850
- for (const rec of Object.values(upstreamsOf(this))) { delete rec.problem; delete rec.backoffUntil; delete rec.backoffMs; delete rec.etag; rec.checkedAt = 0 }
1851
- this.saveMeta()
1852
- this.broadcastBranches()
1853
- if (this.cfg.upstreamAuto) this.checkUpstreams().catch(() => {})
1854
- }
1855
-
1856
1147
  /**
1857
1148
  * What every branch-list message carries: the list and, once the session is a quilt (behind
1858
1149
  * QUILT_SOURCE_URL), its link { id, slug, squares: { branch: square id } } so each member's
@@ -1910,200 +1201,6 @@ class Room {
1910
1201
  for (const ws of this.conns.keys()) send(ws, msg)
1911
1202
  }
1912
1203
 
1913
- /**
1914
- * GitHub said `repo` (owner/name) moved `ref` (a branch name) to `sha`. Every branch of this
1915
- * session that follows it looks now: folders on it are told to fetch (older apps ignore the
1916
- * field and find it on their next look), and the relay checks GitHub itself for a branch no
1917
- * folder can bring in. Returns whether any branch follows it.
1918
- */
1919
- /** Whether session `other` has this session's owner in it (its owner or a member): then the app may offer to open it ("Open the live session", plan §8). */
1920
- sameOwner (other) {
1921
- if (!other || !this.meta.ownerSub) return false
1922
- if (this.dataDirOf) {
1923
- try {
1924
- const m = JSON.parse(fs.readFileSync(path.join(this.dataDirOf, `${other}.json`), 'utf8'))
1925
- return m.ownerSub === this.meta.ownerSub || !!(m.members && m.members[this.meta.ownerSub])
1926
- } catch {}
1927
- }
1928
- // On another relay machine: the accounts API knows who owns it and who has been in it (asked once, then remembered).
1929
- if (this.cluster) return this.cluster.sameOwner(other, this.meta.ownerSub, () => { if (!this.destroyed) this.broadcastBranches() }) === true
1930
- return false
1931
- }
1932
-
1933
- /** The relay pushed `sha` to branch `key`'s upstream: folders on it fetch now and follow it (no file is written: the session has it). */
1934
- tellPushed (key, sha) {
1935
- const msg = jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), pushed: { branch: key, sha } })
1936
- for (const ws of this.conns.keys()) send(ws, msg)
1937
- }
1938
-
1939
- /** Session → GitHub for this room (relay-outbound.js): batches per branch, the push. */
1940
- get outbound () {
1941
- if (!this._outbound) this._outbound = outboundFor(this)
1942
- return this._outbound
1943
- }
1944
-
1945
- /** Where the git of `repo` ({ slug, host, owner, name }) is: GitHub, or wherever tests say. */
1946
- gitRemoteUrl (repo) {
1947
- return this.cfg.gitRemote ? this.cfg.gitRemote(repo.slug) : `https://${repo.slug}.git`
1948
- }
1949
-
1950
- /** What signs the relay's git in to read `repo`: the owner's token, a member's GitHub connection, the relay's own token, or nothing (a public repository). */
1951
- async gitToken (repo, { access = 'read', actingFor = '' } = {}) {
1952
- return (await this.gitTokenState(repo, { access, actingFor })).token
1953
- }
1954
-
1955
- /** { token } for the relay's git on `repo`, or { token: null, state, why? } saying why not (plan §2: the workspace's access mode, the org's Require GitHub). */
1956
- async gitTokenState (repo, { access = 'read', actingFor = '' } = {}) {
1957
- if (validToken(this.meta.githubToken)) return { token: this.meta.githubToken }
1958
- const r = await appCredentials(this, `${repo.owner}/${repo.name}`, { access, fresh: false, actingFor })
1959
- if (r.token) return { token: r.token }
1960
- if (r.state === 'not-allowed' || r.require) return { token: null, state: r.state, ...(r.why ? { why: r.why } : {}), ...(r.require ? { require: true } : {}) }
1961
- return { token: validToken(this.cfg.githubToken) ? this.cfg.githubToken : null, state: r.state }
1962
- }
1963
-
1964
- /** A check suite finished on `repo`: the pull requests this room's tasks are in are looked at now (relay-commit.js reviewPulls). */
1965
- noteChecks ({ repo, sha = '', branch = '', conclusion = '' }) {
1966
- const want = String(repo).toLowerCase().replace(/^github\.com\//, '')
1967
- const follows = Object.values(upstreamsOf(this)).some((rec) => rec && rec.repo && String(rec.repo).toLowerCase().replace(/^github\.com\//, '') === want)
1968
- if (!follows) return false
1969
- // Checks on a commit the relay pushed with merged files in it (relay-clash.js): failing tells both parties; nothing is held.
1970
- if (this.cfg.gitSync && this.mirrors && /^[0-9a-f]{40}$/.test(sha)) {
1971
- for (const [key, rec] of Object.entries(upstreamsOf(this))) {
1972
- if (!rec || String(rec.repo).toLowerCase().replace(/^github\.com\//, '') !== want || (branch && rec.ref !== branch && !(rec.ref && branch === `quilt/live/${rec.ref}`))) continue
1973
- const repoObj = parseRepo(`https://${rec.repo}`)
1974
- this.mirrors.queue(repoObj.slug, rec.ref, () => noteMergeChecks(this, key, { sha, conclusion })).then((r) => { if (r.length) { this.saveMeta(); this.broadcastBranches() } }).catch((err) => this.log(`[${this.name}] ${key}: could not look at the checks on ${sha.slice(0, 7)}: ${err.message}`))
1975
- }
1976
- }
1977
- reviewPulls(this, { fetch: this.githubFetch, log: this.log }).catch((err) => this.log(`[${this.name}] could not look at the pull requests: ${err.message}`))
1978
- // CI failing on a branch the session follows is a task on the board until it passes again (relay-ci.js). Kept in
1979
- // the room's meta until noted, so a restart meanwhile (a deploy) notes it when the room is back.
1980
- if (branch && sha) {
1981
- const ev = { repo, sha, branch, conclusion, at: Date.now() }
1982
- this.meta.ciPending = [...(Array.isArray(this.meta.ciPending) ? this.meta.ciPending : []), ev].slice(-50)
1983
- this.saveMeta()
1984
- this.runCi(ev)
1985
- }
1986
- return true
1987
- }
1988
-
1989
- /** Notes CI result `ev` on the board (relay-ci.js), then drops it from the room's pending ones: unless the room shut down first. */
1990
- runCi (ev) {
1991
- noteCiResult(this, ev, { fetch: this.githubFetch, log: this.log }).finally(() => {
1992
- if (this.destroyed) return
1993
- const same = (x) => x && x.sha === ev.sha && x.branch === ev.branch && x.conclusion === ev.conclusion && x.at === ev.at
1994
- this.meta.ciPending = (Array.isArray(this.meta.ciPending) ? this.meta.ciPending : []).filter((x) => !same(x))
1995
- this.saveMeta()
1996
- })
1997
- }
1998
-
1999
- /** GitHub says pull request `number` on `repo` changed: looked at now when this room follows that repository (relay-commit.js reviewPulls). */
2000
- notePull ({ repo, number, action = '', merged = false, head = '', base = '', sha = '' }) {
2001
- const want = String(repo).toLowerCase().replace(/^github\.com\//, '')
2002
- const recs = upstreamsOf(this)
2003
- const follows = Object.values(recs).some((rec) => rec && rec.repo && String(rec.repo).toLowerCase().replace(/^github\.com\//, '') === want)
2004
- if (!follows) return false
2005
- // §5.6: the pull request's source branch, when the session has it, is marked merged (the branch list says so; a deletion on GitHub then moves its members to the base).
2006
- if (merged && head) {
2007
- const key = this.resolveKey ? this.resolveKey(head) : head
2008
- const rec = recs[key]
2009
- if (rec && String(rec.repo).toLowerCase().replace(/^github\.com\//, '') === want && rec.ref === head) {
2010
- rec.merged = { at: Date.now(), into: base || '', number, ...(sha ? { sha } : {}) }
2011
- this.saveMeta(); this.broadcastBranches()
2012
- this.trimMergedBranches() // gone now when nobody is on it; else the sweep takes it once they've left
2013
- }
2014
- }
2015
- // Addendum 16.5: the live pull request of a protected branch (or of a session without the branch's lease).
2016
- if (this.cfg.gitSync && this.meta.sync) {
2017
- for (const [key, rec] of Object.entries(recs)) {
2018
- const p = this.meta.sync[key] && this.meta.sync[key].protected
2019
- if (!rec || !p || !p.pr || p.pr.number !== number || String(rec.repo).toLowerCase().replace(/^github\.com\//, '') !== want) continue
2020
- if (merged) {
2021
- p.merged = { at: Date.now(), ...(sha ? { sha } : {}) }
2022
- rec.problem = `${rec.ref} is protected: the live pull request #${number} is merged; commits go through a new one (opened with the next commit)`
2023
- this.checkUpstreamsNow([key]).catch((err) => this.log(`[${this.name}] could not look for new commits: ${err.message}`))
2024
- } else if (action === 'closed') {
2025
- p.closed = Date.now()
2026
- rec.problem = `${rec.ref} is protected: pull request #${number} was closed without merging; the session keeps its work, and a new pull request opens with the next commit`
2027
- } else if (action === 'reopened') { delete p.closed; delete p.askedFor }
2028
- this.saveMeta(); this.broadcastBranches()
2029
- }
2030
- }
2031
- reviewPulls(this, { fetch: this.githubFetch, log: this.log, only: number }).catch((err) => this.log(`[${this.name}] could not look at pull request #${number}: ${err.message}`))
2032
- return true
2033
- }
2034
-
2035
- /**
2036
- * GitHub said `repo` moved `ref` (a branch): `after` is its new head (`sha` from an older API),
2037
- * `before` the old one, `forced` a rewrite, `deleted` or `created` the branch itself. Every
2038
- * branch of this session that follows it looks now: folders on it are told, and the relay reads
2039
- * the truth from git (only the ref name is trusted). A push whose head is the one the relay
2040
- * itself pushed is an echo, and ignored. Returns 'echo', true (a branch follows it) or false.
2041
- */
2042
- notePush ({ repo, ref, sha = '', after = '', before = '', forced = false, deleted = false, created = false }) {
2043
- const want = String(repo).toLowerCase().replace(/^github\.com\//, '')
2044
- const head = typeof after === 'string' && after ? after : (typeof sha === 'string' ? sha : '')
2045
- let hit = false
2046
- let echo = false
2047
- for (const [key, rec] of Object.entries(upstreamsOf(this))) {
2048
- if (!rec || !rec.repo || rec.ref !== ref || String(rec.repo).toLowerCase().replace(/^github\.com\//, '') !== want) continue
2049
- hit = true
2050
- rec.webhookAt = Date.now()
2051
- if (deleted) { this.noteBranchDeleted(key, rec); continue } // §5.4
2052
- if (rec.gone && (head || created)) { delete rec.gone; delete rec.problem; this.saveMeta() } // pushed again: the branch is back on GitHub
2053
- if (created && !head) { this.saveMeta(); continue } // a branch made on GitHub: in the menu, nothing to bring in yet
2054
- const sync = this.cfg.gitSync && this.meta.sync && this.meta.sync[key]
2055
- if (head && sync && sync.pushed === head && sync.base === head) { echo = true; this.saveMeta(); continue } // our own push, come back round
2056
- const at = this.cfg.gitSync && this.mirrors && this.meta.sync && this.meta.sync[key] && this.meta.sync[key].base ? this.meta.sync[key].base : rec.sha
2057
- if (head && at === head) { this.saveMeta(); continue } // already there
2058
- const msg = jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), pushed: { branch: key, sha: head, ...(forced ? { forced: true } : {}) } })
2059
- for (const ws of this.conns.keys()) send(ws, msg)
2060
- this.checkUpstreamsNow([key]).catch((err) => this.log(`[${this.name}] could not look for new commits: ${err.message}`))
2061
- }
2062
- // §5.4: a branch created on GitHub that the session doesn't have appears in the branch menu, not synced until someone opens it.
2063
- if (created && !hit && ref && validBranchKey(ref) && !this.meta.branches[this.resolveKey ? this.resolveKey(ref) : ref]) {
2064
- const follows = Object.values(upstreamsOf(this)).some((rec) => rec && rec.repo && String(rec.repo).toLowerCase().replace(/^github\.com\//, '') === want)
2065
- if (follows) {
2066
- if (!this.meta.remoteBranches || typeof this.meta.remoteBranches !== 'object') this.meta.remoteBranches = {}
2067
- this.meta.remoteBranches[ref] = { at: Date.now() }
2068
- this.saveMeta(); this.broadcastBranches()
2069
- return true
2070
- }
2071
- }
2072
- if (deleted && !hit && this.meta.remoteBranches && this.meta.remoteBranches[ref]) { delete this.meta.remoteBranches[ref]; this.saveMeta(); this.broadcastBranches(); return true }
2073
- return hit ? (echo ? 'echo' : true) : false
2074
- }
2075
-
2076
- /**
2077
- * A synced branch was deleted on GitHub (§5.4). After its pull request merged, its members are moved to the
2078
- * branch it merged into (hosted agents are moved; people's folders are told) and told. Otherwise the session
2079
- * keeps the branch: it comes back with the next commit, unless the owner turned that off (recreateDeleted).
2080
- */
2081
- noteBranchDeleted (key, rec) {
2082
- const now = Date.now()
2083
- rec.gone = now
2084
- const merged = rec.merged && rec.merged.into ? rec.merged : null
2085
- if (merged) {
2086
- const into = this.resolveKey ? this.resolveKey(merged.into) : merged.into
2087
- const where = this.meta.branches[into] ? into : this.defaultKey
2088
- rec.problem = `${rec.ref} was merged into ${merged.into} (pull request #${merged.number}) and deleted on GitHub`
2089
- const moved = []
2090
- for (const [id, k] of Object.entries(this.meta.hostedBranch || {})) {
2091
- if (k !== key) continue
2092
- const m = this.meta.members[id]
2093
- try { this.setHostedBranch(id, where, {}); moved.push(m && m.name) } catch {}
2094
- }
2095
- for (const ws of this.conns.keys()) if (ws.branch === key && this.names && this.names.get(ws) && !moved.includes(this.names.get(ws))) moved.push(this.names.get(ws))
2096
- if (this.postChat) for (const name of [...new Set(moved.filter(Boolean))]) this.postChat({ by: RELAY_BY, to: name, kind: 'nudge', text: `${rec.ref} was merged into ${merged.into} and deleted on GitHub (pull request #${merged.number}): you are on ${where} now${this.meta.hostedBranch && Object.values(this.meta.hostedBranch).includes(where) ? '' : ' (a folder: git checkout ' + where + ')'}.` })
2097
- } else if (this.meta.recreateDeleted === true) {
2098
- rec.problem = `${rec.ref} was deleted on GitHub: the session keeps it, and it comes back with the next commit (the owner's choice)`
2099
- } else {
2100
- // Addendum 16.10: a deletion on GitHub is meant; publication pauses, the work stays in the session.
2101
- rec.problem = `${rec.ref} was deleted on GitHub: the session keeps it and its work, and nothing is pushed there unless the owner lets Quilt recreate deleted branches`
2102
- }
2103
- this.log(`[${this.name}] ${key}: ${rec.problem}`)
2104
- this.saveMeta(); this.broadcastBranches()
2105
- }
2106
-
2107
1204
  /**
2108
1205
  * A connection's branch request; returns the reply fields. join: subscribe to a branch's
2109
1206
  * document (one at a time), adding the branch to the session when new. confirm: the relay
@@ -2151,8 +1248,6 @@ class Room {
2151
1248
  closeSoon(ws, CLOSE_NEEDS_UPDATE, NEEDS_UPDATE)
2152
1249
  throw new Error(NEEDS_UPDATE)
2153
1250
  }
2154
- // A new branch an app asks to keep as differences from its commit (`diffBase`, one GitHub has).
2155
- const diff = created && req.form === DIFF && /^[0-9a-f]{40,64}$/.test(String(req.diffBase || '')) && (ws.features || []).includes('branch-diffs') && this.diffCapable(req.repo)
2156
1251
  if (ws.branch && ws.branch !== k) {
2157
1252
  // Kept a moment longer: a sync message the app sent for it just before switching,
2158
1253
  // still in flight, is applied rather than silently dropped (handle, MSG_SYNC).
@@ -2163,7 +1258,6 @@ class Room {
2163
1258
  ws.branch = k // the document, as the relay keeps it
2164
1259
  ws.branchAs = key // as the app named it (∅ without git): its sync messages carry this
2165
1260
  this.noteBranch(k, { by: this.names.get(ws) || '', base: req.base })
2166
- if (diff) this.startDiff(k, e, req.diffBase, req.repo)
2167
1261
  this.noteBranchSeen(k, { force: true, used: true })
2168
1262
  // Everyone hears a new branch, and a default branch that just took its real name.
2169
1263
  return { ok: true, branch: k, created, base: this.meta.branches[k].base, epoch: this.branchEpoch(k), form: isDiff(e) ? DIFF : 'full', ...(isDiff(e) ? { diffBase: e.diffBase } : {}), listChanged: created || adopted }
@@ -2713,27 +1807,6 @@ class Room {
2713
1807
  return head === null ? null : { head, versions: [...ids].map(text).filter((x) => typeof x === 'string') }
2714
1808
  }
2715
1809
 
2716
- async mainHistoryOf (paths) {
2717
- if (!paths.length) return new Map()
2718
- const up = (this.meta.upstreams || {})[this.defaultKey]
2719
- if (!up || !/^[0-9a-f]{40,64}$/.test(String(up.sha || '')) || !this.mirrors || !this.cfg.gitSync) return null
2720
- let m = null
2721
- try { m = await mirrorFor(this, this.defaultKey) } catch {}
2722
- if (!m || !(await ensureCommit(m.dir, up.sha, { token: m.token }))) return null
2723
- const out = new Map()
2724
- for (let i = 0; i < paths.length; i += 500) {
2725
- const r = await runGit(m.dir, ['log', '--format=', '--raw', '--no-abbrev', '--no-renames', '--root', '-m', up.sha, '--', ...paths.slice(i, i + 500)])
2726
- if (!r.ok) return null
2727
- for (const line of r.out.split('\n')) {
2728
- const x = /^:\d+ \d+ ([0-9a-f]{40,64}) ([0-9a-f]{40,64}) \w+\t(.+)$/.exec(line)
2729
- if (!x) continue
2730
- if (!out.has(x[3])) out.set(x[3], new Set())
2731
- out.get(x[3]).add(x[1]).add(x[2])
2732
- }
2733
- }
2734
- return out
2735
- }
2736
-
2737
1810
  /**
2738
1811
  * One layout move of this room at a time, here and on every relay machine (a lease per room,
2739
1812
  * relay-lease.js). With `seam` (the owner's), what a move stopped part way left in Seam
@@ -2985,14 +2058,12 @@ class Room {
2985
2058
 
2986
2059
  /**
2987
2060
  * GitHub's rules for `branch` of `repo` (owner/name): an array, or null when GitHub could not be
2988
- * asked. Read with Quilt's GitHub App through a member's connection, else without a token (public).
2061
+ * asked. Read without a token: Quilt no longer holds GitHub credentials, so a private repository's are not read (null).
2989
2062
  */
2990
2063
  async githubRulesOf (repo, branch) {
2991
2064
  if (this.githubRules) return this.githubRules(repo, branch)
2992
- const cred = await appCredentials(this, repo, { access: 'read' }).catch(() => ({}))
2993
- const f = this.githubFetch || globalThis.fetch
2994
2065
  try {
2995
- const res = await f(`https://api.github.com/repos/${repo}/rules/branches/${encodeURIComponent(branch)}`, { headers: { accept: 'application/vnd.github+json', 'user-agent': 'quilt-relay', ...(cred && cred.token ? { authorization: `Bearer ${cred.token}` } : {}) } })
2066
+ const res = await globalThis.fetch(`https://api.github.com/repos/${repo}/rules/branches/${encodeURIComponent(branch)}`, { headers: { accept: 'application/vnd.github+json', 'user-agent': 'quilt-relay' } })
2996
2067
  if (!res.ok) return null
2997
2068
  const rules = await res.json()
2998
2069
  return Array.isArray(rules) ? rules : null
@@ -3020,13 +2091,8 @@ class Room {
3020
2091
  const e = this.store.load(key)
3021
2092
  return e ? branchFiles(e.doc, tree, { diff: isDiff(e), unheld: atTip.has(key) }) : {}
3022
2093
  }
3023
- // ...nor does a full document there carry the branch's older copies of main's files (git's history of main says which).
3024
- const mainsOld = async (key, files, head) => {
3025
- const e = this.store.load(key)
3026
- if (!atTip.has(key) || !e || isDiff(e)) return files
3027
- const history = await this.mainHistoryOf([...changedGitIds(files, head).keys()])
3028
- return history ? dropOldCopies(files, head, history) : files
3029
- }
2094
+ // A full document's older copies of main's files were told apart by git's history of main, which the relay no longer keeps.
2095
+ const mainsOld = async (key, files) => files
3030
2096
  // Members keep writing their branch documents until layout 3 is written: an edit from now on
3031
2097
  // (before its files are read, or after) marks its branch, so the copy is made again from what
3032
2098
  // it holds then and checked with no wait between (the catch-up below). Watched from before any
@@ -3699,8 +2765,7 @@ class Room {
3699
2765
  personCanAdmit (a) { return canAdmit(a, this.admitBy) }
3700
2766
 
3701
2767
  accessMessage (a) {
3702
- // The owner learns whether a GitHub token is set (never the token itself); nobody else hears of it.
3703
- return { state: 'approved', role: a.role, scopes: a.scopes || [], scopesExcept: a.scopesExcept || [], talk: a.talk !== false, owner: !!a.owner, controlled: this.controlled, admitBy: this.admitBy, canAdmit: this.personCanAdmit(a), agentCommits: agentCommitsOf(this), ...(this.meta.agentCommitsSet ? { agentCommitsBy: { by: this.meta.agentCommitsSet.by, name: this.meta.agentCommitsSet.name, mode: this.meta.agentCommitsSet.mode } } : {}), agentsCanCommit: agentsCanCommit(this, this.defaultKey), ...(this.cfg.gitSync ? { githubSync: this.meta.githubSync !== false, largeToRelay: this.meta.largeToRelay === true } : {}), ...(a.owner ? { githubToken: !!this.meta.githubToken } : {}) }
2768
+ return { state: 'approved', role: a.role, scopes: a.scopes || [], scopesExcept: a.scopesExcept || [], talk: a.talk !== false, owner: !!a.owner, controlled: this.controlled, admitBy: this.admitBy, canAdmit: this.personCanAdmit(a) }
3704
2769
  }
3705
2770
 
3706
2771
  /** Tracks restricted connections so their file changes are checked. */
@@ -3708,8 +2773,7 @@ class Room {
3708
2773
  this.access.set(ws, a)
3709
2774
  // An agent with a webhook, here from a computer: the relay delivers for it in this session too.
3710
2775
  if (this.webhookApi && !ws.hosted && a && a.state !== 'pending') { try { this.webhookApi.joined(this, ws, a) } catch {} }
3711
- // With live sync to GitHub, agents' changes are checked too: a file held for a clash is theirs to propose, not to write (relay-clash.js).
3712
- const restricted = a.role === 'viewer' || (a.scopes && a.scopes.length) || (a.scopesExcept && a.scopesExcept.length) || a.talk === false || (a.kind === 'agent' && !!this.cfg.gitSync)
2776
+ const restricted = a.role === 'viewer' || (a.scopes && a.scopes.length) || (a.scopesExcept && a.scopesExcept.length) || a.talk === false
3713
2777
  if (restricted) this.guard.trackedOrigins.add(ws)
3714
2778
  else this.guard.trackedOrigins.delete(ws)
3715
2779
  }
@@ -3767,33 +2831,6 @@ class Room {
3767
2831
  }
3768
2832
  return { ok: true }
3769
2833
  }
3770
- if (['syncUpstream', 'commit', 'gitToken', 'pulls', 'merge', 'githubRecheck', 'unpublished', 'finishTask'].includes(req.op)) throw new Error(`${req.op} is answered by itself`) // see the MSG_ADMIN handler
3771
- // A folder's git pushed: what went up goes on record, and the chat hears it (relay-commit.js).
3772
- if (req.op === 'pushed') {
3773
- if (!ws.branch) throw new Error('not in this session')
3774
- return notePushedFor(this, { who: { name: me.name, kind: me.kind === 'agent' ? 'agent' : 'human', access: me }, key: ws.branch, branch: String(req.branch || ''), sha: String(req.sha || ''), count: req.count, files: req.files, subject: typeof req.subject === 'string' ? req.subject : '' }, { fetch: this.githubFetch, log: this.log })
3775
- }
3776
- if (req.op === 'githubSync') {
3777
- // The owner's "Sync with GitHub" switch (plan §15): on by default once a connection covers the repository.
3778
- if (!me.owner) throw new Error('only the session owner can do that')
3779
- this.meta.githubSync = req.on !== false
3780
- this.saveMeta(); this.broadcastBranches()
3781
- for (const [cws, acc] of this.access) send(cws, jsonMessage(MSG_ACCESS, this.accessMessage(acc)))
3782
- return { ok: true, githubSync: this.meta.githubSync }
3783
- }
3784
- if (req.op === 'largeToRelay') {
3785
- // Large files on branches that sync with GitHub (plan §10): the owner agrees that the relay may read them, so it can commit them.
3786
- if (!me.owner) throw new Error('only the session owner can do that')
3787
- this.meta.largeToRelay = req.on === true
3788
- this.saveMeta(); this.broadcastBranches()
3789
- for (const [cws, acc] of this.access) send(cws, jsonMessage(MSG_ACCESS, this.accessMessage(acc)))
3790
- return { ok: true, largeToRelay: this.meta.largeToRelay }
3791
- }
3792
- if (req.op === 'agentCommits') {
3793
- // What agents may do with commits (relay-commit.js): the owner's call.
3794
- if (!me.owner) throw new Error('only the session owner can do that')
3795
- return { ok: true, agentCommits: this.setAgentCommits(req.agentCommits) }
3796
- }
3797
2834
  if (req.op === 'workspace') {
3798
2835
  // Which workspace the session is in (the owner's app knows): webhook events say so, so an agent in several can tell them apart.
3799
2836
  if (!me.owner) throw new Error('only the session owner can do that')
@@ -3801,8 +2838,6 @@ class Room {
3801
2838
  if ((this.meta.workspace || '') !== id) {
3802
2839
  this.meta.workspace = id || undefined
3803
2840
  this.saveMeta()
3804
- // The app links the session to the workspace on the accounts API first: its git rule applies now.
3805
- this.refreshPolicy()
3806
2841
  }
3807
2842
  return { ok: true, workspace: this.meta.workspace || '' }
3808
2843
  }
@@ -3843,12 +2878,6 @@ class Room {
3843
2878
  const l = extendChatLink(this, String(req.key || ''), req.minutes)
3844
2879
  return { ok: true, name: l.name, expiresAt: l.expiresAt }
3845
2880
  }
3846
- if (req.op === 'githubToken') {
3847
- // A read-only token the relay uses to bring commits in from a private repository while no
3848
- // folder is online (relay-upstream.js). Kept on the relay only: never in presence, the
3849
- // branch list, a reply or the log. An empty token clears it.
3850
- return this.setGithubToken(req.token)
3851
- }
3852
2881
  if (req.op === 'end') {
3853
2882
  // Reply first; the relay then sends everyone away and deletes the room.
3854
2883
  setTimeout(() => this.onEnd && this.onEnd(), 50)
@@ -3924,8 +2953,6 @@ class Room {
3924
2953
  const theirs = (x) => x.byId ? gone.includes(x.byId) : names.includes(x.by)
3925
2954
  this.dropRequests(theirs)
3926
2955
  this.dropClaims(theirs, (c) => `${c.by} was removed from the session.`)
3927
- for (const n of names) this.revokeAgentTokens({ name: n }) // a removed agent's folder can't push or fetch any more
3928
- for (const id of gone) this.revokeAgentTokens({ account: id }) // nor a removed person's
3929
2956
  for (const id of gone) delete this.meta.members[id]
3930
2957
  // A pass issued before now can't bring them back by its grant (see passGrant). Removals
3931
2958
  // older than a pass lasts can match no valid pass, so they go; the newest are kept.
@@ -4028,13 +3055,7 @@ class Room {
4028
3055
  branchPaths (branch) {
4029
3056
  const k = this.resolveKey(branch || this.defaultKey)
4030
3057
  const e = this.store.load(k)
4031
- // A diff branch's unchanged files are git's (branch-view.js): its tree at the base, once read (read now for next time).
4032
- let git = []
4033
- if (isDiff(e)) {
4034
- const tree = cachedTree(this, baseOf(this, k, e))
4035
- if (tree) git = [...tree.keys()].filter((rel) => !e.gone.has(rel)); else this.warmTree(k)
4036
- }
4037
- return [...git, ...e.files.keys(), ...e.blobs.keys(), ...this.doc.getMap('files').keys(), ...this.doc.getMap('blobs').keys()]
3058
+ return [...e.files.keys(), ...e.blobs.keys(), ...this.doc.getMap('files').keys(), ...this.doc.getMap('blobs').keys()]
4038
3059
  }
4039
3060
 
4040
3061
  /** Who holds a claim: their account with sign-in on, otherwise their name. */
@@ -4391,8 +3412,6 @@ class Room {
4391
3412
  if (!prev && c.queue.length >= MAX_QUEUE) throw new Error(`${c.queue.length} are already waiting for ${c.pattern}`)
4392
3413
  const r = prev || { id: crypto.randomBytes(6).toString('hex'), by: name, ...(id ? { byId: id } : {}), ...(who.persona ? { of: who.of } : {}), ts: Date.now() }
4393
3414
  Object.assign(r, { path: file, title, description, ...(task ? { task } : {}) })
4394
- // Asked for by a clash task's assignee for the merge: let go of when the task is done (dropClashClaims).
4395
- if (typeof req.clash === 'string' && /^[0-9a-f]{16}$/.test(req.clash) && this.tasks.get(req.clash)?.assignee === name) r.clash = req.clash
4396
3415
  if (!prev) c.queue.push(r)
4397
3416
  this.postChat({ by: name, to: c.by, kind: 'queue', path: file, text: `📥 File queue · ${file}: ${`${title}${description ? ` — ${description}` : ''}`.replace(/[.!?]+$/, '')}. When you're done with it, hand it off to me with your context.` })
4398
3417
  return { ok: true, request: r.id, position: c.queue.indexOf(r) + 1, holder: c.by, pattern: c.pattern }
@@ -4761,75 +3780,11 @@ class Room {
4761
3780
  const claims = this.claimList(other.branch)
4762
3781
  send(other, jsonMessage(MSG_CLAIMS, other === ws ? { claims, reply } : { claims }))
4763
3782
  }
4764
- this.noteClaimsChanged()
4765
3783
  } else if (type === MSG_ADMIN) {
4766
3784
  let req = {}
4767
3785
  let reply
4768
3786
  try {
4769
3787
  req = JSON.parse(decoding.readVarString(dec))
4770
- // quilt_sync_branch from a folder that can't bring commits in itself (no git, or its fetch
4771
- // fails): any member let in may ask the relay to look at GitHub for its branch now.
4772
- if (req.op === 'syncUpstream') {
4773
- const id = req.id
4774
- if (!this.access.get(ws) || !ws.branch) throw new Error('not in this session')
4775
- this.syncUpstreamNow(ws.branch).then((sync) => ({ id, ok: true, sync }), (err) => ({ id, ok: false, error: err.message }))
4776
- .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
4777
- return
4778
- }
4779
- // A folder's git asking for a token for the session's repository (Quilt's credential helper, in
4780
- // agents' and people's folders): read to fetch or clone, write to push while git is on for everyone here.
4781
- if (req.op === 'gitToken') {
4782
- const id = req.id
4783
- const a = this.access.get(ws)
4784
- if (!a || !ws.branch) throw new Error('not in this session')
4785
- // In a session that is a quilt, an agent's git gets no GitHub token: it uses Seam (session-seam.js).
4786
- const onSeam = gitTokenRefusal(quiltOf(this.meta), a.kind === 'agent' ? 'agent' : 'human')
4787
- ;(onSeam ? Promise.reject(new Error(onSeam)) : this.gitTokenFor(ws.branch, { access: req.access === 'write' ? 'write' : 'read', who: { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', id: a.id }, repo: typeof req.repo === 'string' ? req.repo : null }))
4788
- .then((git) => ({ id, ok: true, git }), (err) => ({ id, ok: false, error: err.message }))
4789
- .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
4790
- return
4791
- }
4792
- // Someone just connected GitHub (or asks whether it is): the connection is checked again now.
4793
- if (req.op === 'githubRecheck') {
4794
- const id = req.id
4795
- if (!this.access.get(ws) || !ws.branch) throw new Error('not in this session')
4796
- const key = this.resolveKey(ws.branch)
4797
- noteConnection(this, key, { force: true }).then((changed) => { if (changed) { this.saveMeta(); this.broadcastBranches() } return { id, ok: true, github: relayEntry(upstreamsOf(this)[key])?.github || null } }, (err) => ({ id, ok: false, error: err.message }))
4798
- .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
4799
- return
4800
- }
4801
- // Which of a member's files (or a task's) aren't in git on GitHub yet (relay-commit.js).
4802
- if (req.op === 'unpublished') {
4803
- const id = req.id
4804
- const a = this.access.get(ws)
4805
- if (!a || !ws.branch) throw new Error('not in this session')
4806
- unpublishedFor(this, { key: ws.branch, who: a.name, files: Array.isArray(req.files) ? req.files : null, task: typeof req.task === 'string' ? req.task : null, column: typeof req.column === 'string' ? req.column : '' }, { fetch: this.githubFetch })
4807
- .then((u) => ({ id, ok: true, unpublished: u }), (err) => ({ id, ok: false, error: err.message }))
4808
- .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
4809
- return
4810
- }
4811
- // The branch's pull requests, merging one, and a task moved to Done (which merges its pull request) (relay-commit.js).
4812
- if (req.op === 'pulls' || req.op === 'merge' || req.op === 'finishTask') {
4813
- const id = req.id
4814
- const a = this.access.get(ws)
4815
- if (!a || !ws.branch) throw new Error('not in this session')
4816
- const who = { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', access: a }
4817
- const run = req.op === 'pulls' ? pullRequestsFor(this, { key: ws.branch }, { fetch: this.githubFetch }) : req.op === 'finishTask' ? finishTaskFor(this, { who, key: ws.branch, task: String(req.task || '') }, { fetch: this.githubFetch, log: this.log }) : mergeForMember(this, { who, key: ws.branch, number: req.number, branch: req.branch, method: req.method, deleteBranch: req.deleteBranch, force: req.force === true }, { fetch: this.githubFetch, log: this.log })
4818
- run.then((out) => ({ id, ok: true, [req.op]: out }), (err) => ({ id, ok: false, error: err.message }))
4819
- .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
4820
- return
4821
- }
4822
- // A person's folder hands its local commits to the relay (plan §6; relay-outbound.js replayBundle).
4823
- if (req.op === 'bundle') {
4824
- const id = req.id
4825
- const a = this.access.get(ws)
4826
- if (!a || (a.state && a.state !== 'approved') || !ws.branch) throw new Error('not in this session')
4827
- if (a.role === 'viewer' || a.kind === 'agent') throw new Error('only people\'s folders hand commits to the relay')
4828
- replayBundle(this, ws.branch, { who: { name: a.name, kind: 'human' }, bundle: String(req.bundle || ''), head: String(req.head || ''), ref: String(req.ref || '') })
4829
- .then((out) => ({ id, ok: true, bundle: out }), (err) => ({ id, ok: false, error: err.message }))
4830
- .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
4831
- return
4832
- }
4833
3788
  // Sent by apps before 0.3.99 for offline edits that clashed with the session's: Quilt merges those on its own now
4834
3789
  // (session.js autoSettle), so there is nothing to hold. Answered ok so an older app doesn't retry.
4835
3790
  if (req.op === 'offlineClash') {
@@ -4837,27 +3792,6 @@ class Room {
4837
3792
  if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r }))
4838
3793
  return
4839
3794
  }
4840
- // A person adopts an agent's branch into theirs (addendum 16.7; relay-outbound.js adoptBranch).
4841
- if (req.op === 'adopt') {
4842
- const id = req.id
4843
- const a = this.access.get(ws)
4844
- if (!a || (a.state && a.state !== 'approved') || !ws.branch || a.role === 'viewer') throw new Error('not in this session')
4845
- if (!this.cfg.gitSync || !this.mirrors) throw new Error('This session doesn\'t sync live with GitHub.')
4846
- adoptBranch(this, ws.branch, { who: { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human' }, from: String(req.from || '') })
4847
- .then((out) => ({ id, ok: true, adopt: out }), (err) => ({ id, ok: false, error: err.message }))
4848
- .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
4849
- return
4850
- }
4851
- // quilt_clash from a folder's AI or app (relay-clash.js): the member's own name and kind.
4852
- if (req.op === 'clash') {
4853
- const id = req.id
4854
- const a = this.access.get(ws)
4855
- if (!a || (a.state && a.state !== 'approved') || !ws.branch) throw new Error('not in this session')
4856
- clashAction(this, { who: { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', access: a }, key: ws.branch, action: String(req.action || ''), path: String(req.path || ''), content: typeof req.content === 'string' ? req.content : null, checks: req.checks && typeof req.checks === 'object' ? req.checks : null, force: req.force === true })
4857
- .then((out) => out.ok ? { id, ok: true, clash: out, text: out.text } : { id, ok: false, error: out.text }, (err) => ({ id, ok: false, error: err.message }))
4858
- .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
4859
- return
4860
- }
4861
3795
  // quilt_webhook_subscribe from a folder: the agent's one webhook, kept and delivered by the relay
4862
3796
  // (relay-webhooks.js), as for a hosted agent. The secret and bearer go in and never come back out.
4863
3797
  if (req.op === 'webhook') {
@@ -4877,16 +3811,6 @@ class Room {
4877
3811
  send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: [], reply: { id: req.id, ok: true } }))
4878
3812
  return
4879
3813
  }
4880
- // quilt_commit from a folder: the relay commits the session's copy of the files to GitHub (relay-commit.js).
4881
- if (req.op === 'commit') {
4882
- const id = req.id
4883
- const a = this.access.get(ws)
4884
- if (!a || !ws.branch) throw new Error('not in this session')
4885
- this.commitFor({ name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', access: a }, ws.branch, req)
4886
- .then((commit) => ({ id, ok: true, commit }), (err) => ({ id, ok: false, error: err.message }))
4887
- .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
4888
- return
4889
- }
4890
3814
  reply = { id: req.id, ...this.adminRequest(ws, req) }
4891
3815
  } catch (err) {
4892
3816
  reply = { id: req.id, ok: false, error: err.message }
@@ -4902,8 +3826,6 @@ class Room {
4902
3826
  destroy () {
4903
3827
  this.destroyed = true
4904
3828
  clearTimeout(this.unloadTimer)
4905
- clearTimeout(this.upstreamSoonTimer)
4906
- clearTimeout(this.upstreamAgainTimer)
4907
3829
  this.save()
4908
3830
  this.store.destroy()
4909
3831
  this.guard.destroy()
@@ -4991,21 +3913,13 @@ function closeSoon (ws, code, reason, ms = 2000) {
4991
3913
  ws.once('close', () => clearTimeout(t))
4992
3914
  }
4993
3915
 
4994
- /** Whether anyone (a folder, a connected app or CLI, a hosted agent) is on branch `key` now. */
4995
- function relayLooksActive (room, key) { return folderOn(room, key) || connectedOn(room, key) || hostedOn(room, key).length > 0 }
4996
-
4997
3916
  export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, log = console.log, presenceOptions = {}, ...opts } = {}) {
4998
3917
  const cfg = relayConfig(opts)
4999
3918
  // Many machines (relay-cluster.js): each room on one of them, the accounts API says which.
5000
3919
  const cc = clusterConfig(opts)
5001
- configureGithubBudget({ anonPerHour: cfg.githubAnonPerHour })
5002
3920
  const passKey = cfg.passPublicKey ? parsePublicKey(cfg.passPublicKey) : null
5003
3921
  if (cfg.passPublicKey && !passKey) throw new Error('QUILT_PASS_PUBLIC_KEY is not an Ed25519 public key (spki, base64url)')
5004
3922
  /** With sign-in on: the request's valid pass, or null. With it off: an empty pass. */
5005
- // The relay's own key for large files on branches that sync with GitHub (relay-filekey.js).
5006
- const relayFileKeyPair = cfg.gitSync ? relayFileKey(dataDir, { pem: opts.relayFileKey ?? process.env.QUILT_RELAY_FILE_KEY ?? '', log }) : null
5007
- // Git's hooks on this machine ask the relay with this (relay-githttp.js): a secret of this process.
5008
- const hookToken = crypto.randomBytes(24).toString('base64url')
5009
3923
  const httpPass = (req, room = '') => {
5010
3924
  if (!passKey) return {}
5011
3925
  const p = verifyPass(String(req.headers['x-quilt-pass'] || ''), passKey)
@@ -5017,20 +3931,13 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
5017
3931
  // Without sign-in the limit is per address, and the message says so (as before).
5018
3932
  const TOO_MANY = passKey ? 'too many new sessions; try again later' : 'too many new sessions from this address; try again later'
5019
3933
  if (dataDir) fs.mkdirSync(dataDir, { recursive: true })
5020
- // Without git http-backend every agent's git request would fail with a 500: agents' folders use GitHub's git instead.
5021
- if (cfg.gitSync && cfg.gitHttp && passKey && !gitHttpBackendWorks(cfg.gitHttpCommand)) {
5022
- cfg.gitHttp = false
5023
- log('⚠️ this machine\'s git has no http-backend (on Alpine it comes with git-daemon): agents\' folders can\'t use git through the relay, so they use GitHub\'s')
5024
- }
5025
- // The relay's git mirrors (relay-git.js): one bare repository per GitHub repository, one writer per branch.
5026
- const mirrors = mirrorManager({ dataDir, log, ...(cfg.pushGapMs !== undefined ? { pushGapMs: cfg.pushGapMs } : {}), ...(cfg.pushesPerHour !== undefined ? { pushesPerHour: cfg.pushesPerHour } : {}) })
5027
- // One live session per repository and branch (relay-lease.js): in the accounts API when this relay has one, else in this process.
3934
+ // The relay's leases (relay-lease.js: one layout move per room): in the accounts API when this relay has one, else in this process.
5028
3935
  const leases = leaseKeeper({ apiUrl: cfg.apiUrl, secret: cfg.relayApiSecret, fetch: opts.apiFetch || globalThis.fetch, dataDir, log, ...(cfg.leaseTtlMs ? { ttlMs: cfg.leaseTtlMs } : {}), ...(cfg.leaseMarginMs !== undefined ? { marginMs: cfg.leaseMarginMs } : {}), holder: `relay:${cc.machineId || os.hostname()}:${process.pid}`.slice(0, 100) })
5029
3936
  // Whether the accounts API has workspaces on, for hosted agents' library tools: asked in
5030
3937
  // the background (never holding up the start) and again every 10 minutes.
5031
3938
  const apiFetch = opts.apiFetch || globalThis.fetch
5032
3939
  {
5033
- const problems = clusterProblems(cc, cfg, { fileKey: relayFileKeyPair && relayFileKeyPair.shared ? 'shared' : '' })
3940
+ const problems = clusterProblems(cc, cfg)
5034
3941
  if (problems.length) throw new Error(`QUILT_CLUSTER is on, but the relay is missing ${problems.join(', ')}`)
5035
3942
  }
5036
3943
  const cluster = cc.on ? new Cluster({ cc, apiUrl: cfg.apiUrl, secret: cfg.relayApiSecret, fetch: apiFetch, log, dataDir }) : null
@@ -5080,8 +3987,6 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
5080
3987
  presence.start()
5081
3988
  }
5082
3989
  const rooms = new Map() // loaded rooms only
5083
- // Repositories renamed or moved on GitHub (relay-repo-moves.js): every session following one follows its new name.
5084
- const repoMoveLog = repoMoves({ dataDir })
5085
3990
  const ipConns = new Map()
5086
3991
  // New sessions are rate-limited per account when sign-in is on, otherwise per address.
5087
3992
  const newRooms = new Map() // starter -> creation timestamps in the last hour
@@ -5123,20 +4028,12 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
5123
4028
  if (tooBigToLoad(name)) return null
5124
4029
  try {
5125
4030
  room = new Room(name, dataDir, cfg, log)
5126
- // Renamed or moved on GitHub while this session wasn't open: its branches follow the new name now.
5127
- room.repoMoves = repoMoveLog
5128
- for (const [, rec] of Object.entries(room.meta.upstreams || {})) {
5129
- if (rec && typeof rec.repo === 'string' && repoMoveLog.resolve(rec.repo) !== rec.repo) applyRepoMove(room, rec.repo, repoMoveLog.resolve(rec.repo))
5130
- }
5131
- room.mirrors = mirrors
5132
4031
  room.leases = leases
5133
4032
  room.cluster = cluster
5134
4033
  room.dataDirOf = dataDir
5135
- room.relayFileKey = relayFileKeyPair // large files on synced branches, once the owner agrees (plan §10)
5136
4034
  room.blobStore = store
5137
- room.apiFetch = apiFetch // the API: credentials for agents' commits (relay-commit.js)
4035
+ room.apiFetch = apiFetch
5138
4036
  room.agentWakes = agentWakes // how each agent wakes, from the API (plans/agent-wake.md)
5139
- room.refreshPolicy() // what agents may commit, when its org or workspace says
5140
4037
  } catch (err) {
5141
4038
  if (!err.unreadable) throw err
5142
4039
  // Logged once, not on every retry; tried again each time, in case the operator repaired it.
@@ -5150,7 +4047,6 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
5150
4047
  if (cloudAis) { room.cloudAiWaker = cloudAis; cloudAis.watch(room) } // people's cloud AIs are woken from it
5151
4048
  if (adoptHosted) adoptHosted(room)
5152
4049
  room.presence = presence
5153
- if (opts.githubFetch) room.githubFetch = opts.githubFetch
5154
4050
  // Idle rooms are saved and dropped from memory (only when they're on disk).
5155
4051
  room.onEmpty = () => {
5156
4052
  if (!dataDir || rooms.get(name) !== room) return
@@ -5418,18 +4314,7 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
5418
4314
  return n
5419
4315
  }
5420
4316
  /** How busy and how big a loaded room is (relay-metrics.js): for /stats and the accounts API's placement. */
5421
- // Git pushes into a room's view (/git/<room>.git) being received now.
5422
- const gitPushes = new Map() // room -> count
5423
- /** Git work running or queued for a room now: pushes being received, and commits, pushes and bring-ins on its branches (relay-git.js). */
5424
- const gitBusy = (room) => {
5425
- let n = gitPushes.get(room.name) || 0
5426
- for (const rec of Object.values((room.meta && room.meta.upstreams) || {})) {
5427
- const repo = rec && rec.repo && rec.ref ? parseRepo(`https://${rec.repo}`) : null
5428
- if (repo) n += mirrors.queue.pending(repo.slug, rec.ref)
5429
- }
5430
- return n
5431
- }
5432
- const roomStats = (room) => ({ room: room.name, conns: room.conns.size, hosted: room.hostedSeen.size, ...room.meter.lastMinute(), docBytes: room.totalBytes(), diskBytes: diskOf(room.name), git: gitBusy(room) })
4317
+ const roomStats = (room) => ({ room: room.name, conns: room.conns.size, hosted: room.hostedSeen.size, ...room.meter.lastMinute(), docBytes: room.totalBytes(), diskBytes: diskOf(room.name) })
5433
4318
  // The machine's own load (memory, event-loop lag, CPU), a minute at a time: for scaling the cluster on load.
5434
4319
  const machineMeter = new MachineMeter()
5435
4320
  /** The machine: rooms loaded, connections, memory, lag, CPU and every room's traffic (`list`: the rooms' stats, when already made). */
@@ -5468,7 +4353,6 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
5468
4353
  unloadForMove(name)
5469
4354
  leases.forget(name)
5470
4355
  removeLocalRoom(dataDir, name)
5471
- fs.rmSync(viewDir(dataDir, name), { recursive: true, force: true })
5472
4356
  for (const h of hosted.values()) if (hostedIn(h, name)) delete h.rooms[name]
5473
4357
  saveHosted()
5474
4358
  cluster.forget(name, to)
@@ -5574,88 +4458,6 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
5574
4458
  if (!fromApi(req)) return json(401, { error: 'only the accounts API may ask this' })
5575
4459
  return json(200, stats())
5576
4460
  }
5577
- // GitHub said a branch moved (the API checked GitHub's signature; src/api/routes/github.js). Each
5578
- // session following that branch looks now: its folders fetch at once, and the relay brings the
5579
- // commits in itself where no folder can. Only a hint: everyone reads the truth from git.
5580
- if (url.pathname === '/github/push' && req.method === 'POST') {
5581
- const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
5582
- const want = cfg.relayApiSecret || ''
5583
- const same = want && auth.length === want.length && crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))
5584
- if (!same) return json(401, { error: 'only the accounts API may say this' })
5585
- return readJson(req, 16 * 1024, (err, b) => {
5586
- if (err || !b || typeof b.repo !== 'string' || typeof b.ref !== 'string') return json(400, { error: 'give repo and ref' })
5587
- let told = 0
5588
- for (const room of rooms.values()) if (room.notePush && room.notePush({ repo: b.repo, ref: b.ref, sha: typeof b.sha === 'string' ? b.sha : '', after: typeof b.after === 'string' ? b.after : '', before: typeof b.before === 'string' ? b.before : '', forced: b.forced === true, deleted: b.deleted === true, created: b.created === true })) told++
5589
- json(200, { ok: true, rooms: told })
5590
- })
5591
- }
5592
- // GitHub says a repository was renamed or moved to another owner (the API relays the webhook): it is
5593
- // remembered, so sessions not open now follow the new name when they load, and every open session
5594
- // following it follows it now: the new name in its branch list (members' folders repoint origin), and
5595
- // its GitHub connection asked about again.
5596
- if (url.pathname === '/github/moved' && req.method === 'POST') {
5597
- const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
5598
- const want = cfg.relayApiSecret || ''
5599
- const same = want && auth.length === want.length && crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))
5600
- if (!same) return json(401, { error: 'only the accounts API may say this' })
5601
- return readJson(req, 16 * 1024, (err, b) => {
5602
- if (err || !b || typeof b.repo !== 'string' || typeof b.to !== 'string') return json(400, { error: 'give repo and to' })
5603
- if (!repoMoveLog.record(b.repo, b.to)) return json(400, { error: 'repo and to must be two GitHub repositories' })
5604
- let told = 0
5605
- for (const room of rooms.values()) {
5606
- const keys = applyRepoMove(room, b.repo, b.to)
5607
- if (!keys.length) continue
5608
- told++
5609
- log(`[${room.name}] ${keys.join(', ')}: GitHub says ${b.repo} is ${b.to} now; the session follows it`)
5610
- room.saveMeta(); room.broadcastBranches(); room.noteConnections({ keys, force: true })
5611
- }
5612
- json(200, { ok: true, rooms: told })
5613
- })
5614
- }
5615
- // A check suite finished on GitHub (the API relays the webhook): every session following that repository looks at the pull requests its tasks are in.
5616
- if (url.pathname === '/github/checks' && req.method === 'POST') {
5617
- const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
5618
- const want = cfg.relayApiSecret || ''
5619
- const same = want && auth.length === want.length && crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))
5620
- if (!same) return json(401, { error: 'only the accounts API may say this' })
5621
- return readJson(req, 16 * 1024, (err, b) => {
5622
- if (err || !b || typeof b.repo !== 'string') return json(400, { error: 'give repo' })
5623
- let told = 0
5624
- for (const room of rooms.values()) if (room.noteChecks && room.noteChecks({ repo: b.repo, sha: typeof b.sha === 'string' ? b.sha : '', branch: typeof b.branch === 'string' ? b.branch : '', conclusion: typeof b.conclusion === 'string' ? b.conclusion : '' })) told++
5625
- json(200, { ok: true, rooms: told })
5626
- })
5627
- }
5628
- // A pull request changed on GitHub (the API relays the webhook): every session following that repository looks at the pull requests its tasks are in now.
5629
- if (url.pathname === '/github/pull' && req.method === 'POST') {
5630
- const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
5631
- const want = cfg.relayApiSecret || ''
5632
- const same = want && auth.length === want.length && crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))
5633
- if (!same) return json(401, { error: 'only the accounts API may say this' })
5634
- return readJson(req, 16 * 1024, (err, b) => {
5635
- if (err || !b || typeof b.repo !== 'string' || !Number.isInteger(b.number)) return json(400, { error: 'give repo and number' })
5636
- let told = 0
5637
- for (const room of rooms.values()) if (room.notePull && room.notePull({ repo: b.repo, number: b.number, action: typeof b.action === 'string' ? b.action : '', merged: b.merged === true, head: typeof b.head === 'string' ? b.head : '', base: typeof b.base === 'string' ? b.base : '', sha: typeof b.sha === 'string' ? b.sha : '' })) told++
5638
- json(200, { ok: true, rooms: told })
5639
- })
5640
- }
5641
- // An org or workspace changed what agents may commit (the accounts API): every open session asks again now.
5642
- // With { room }, only that session (one just put in or taken out of a workspace), if this machine holds it.
5643
- if (url.pathname === '/github/policy' && req.method === 'POST') {
5644
- const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
5645
- const want = cfg.relayApiSecret || ''
5646
- const same = want && auth.length === want.length && crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))
5647
- if (!same) return json(401, { error: 'only the accounts API may say this' })
5648
- return readJson(req, 4 * 1024, (err, b) => {
5649
- const one = !err && b && typeof b.room === 'string' && b.room ? b.room : ''
5650
- if (one) {
5651
- const room = ROOM_RE.test(one) ? rooms.get(one) : null
5652
- if (room) room.refreshPolicy()
5653
- return json(200, { ok: true, rooms: room ? 1 : 0 })
5654
- }
5655
- for (const room of rooms.values()) room.refreshPolicy()
5656
- json(200, { ok: true, rooms: rooms.size })
5657
- })
5658
- }
5659
4461
  // Mail came to a session's address (the accounts API, routes/mail.js): a line in its chat, which
5660
4462
  // wakes the session's email agents (inbox.js). Only the machine holding the session writes it.
5661
4463
  if (url.pathname === '/mail/arrived' && req.method === 'POST') {
@@ -5694,22 +4496,6 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
5694
4496
  json(200, { sessions })
5695
4497
  })
5696
4498
  }
5697
- // Someone connected GitHub (the API's callback): every session they are in checks its repository again now.
5698
- if (url.pathname === '/github/connected' && req.method === 'POST') {
5699
- const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
5700
- const want = cfg.relayApiSecret || ''
5701
- const same = want && auth.length === want.length && crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))
5702
- if (!same) return json(401, { error: 'only the accounts API may say this' })
5703
- return readJson(req, 4 * 1024, (err, b) => {
5704
- if (err || !b || !/^person:[A-Za-z0-9_-]{1,64}$/.test(String(b.account || ''))) return json(400, { error: 'give the account' })
5705
- let n = 0
5706
- for (const room of rooms.values()) {
5707
- if (!room.meta || (room.meta.ownerSub !== b.account && !(room.meta.members && room.meta.members[b.account]))) continue
5708
- room.noteConnections({ force: true }); n++
5709
- }
5710
- json(200, { ok: true, rooms: n })
5711
- })
5712
- }
5713
4499
  // Making a session a quilt from the accounts API (POST /v1/sessions/:room/migrate, which checked the
5714
4500
  // owner's sign-in): { room, account, dry_run } plans it; { room, account, token } makes it, as the
5715
4501
  // owner, with their Seam sign-in. The room's own owner check still applies (Room.quiltPlan).
@@ -5771,10 +4557,6 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
5771
4557
  return readJson(req, 4096, (err, b) => {
5772
4558
  if (err) return json(400, { error: err.message })
5773
4559
  if (action === 'release') {
5774
- // Never mid git push or commit: the API tries again later (or another room).
5775
- const loaded = rooms.get(name)
5776
- const busy = loaded ? gitBusy(loaded) : gitPushes.get(name) || 0
5777
- if (busy) return json(409, { error: `${name} has git work running (${busy}); try again later` })
5778
4560
  letRoomGo(name)
5779
4561
  return json(200, { ok: true })
5780
4562
  }
@@ -5832,63 +4614,6 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
5832
4614
  res.end(JSON.stringify({ ok: true, aiSeenAt: prev.aiSeenAt || 0 }))
5833
4615
  })
5834
4616
  }
5835
- // Agents' folders use git through the relay (relay-githttp.js, plan §9): clone, fetch and push a session's
5836
- // branches at /git/<room>.git, signed in with the member's Quilt pass (git's password; quilt git-credential).
5837
- const gm = url.pathname.match(/^\/git\/([A-Za-z0-9_-]{1,64})\.git(\/info\/refs|\/git-upload-pack|\/git-receive-pack)$/)
5838
- if (gm) {
5839
- if (!cfg.gitSync || !cfg.gitHttp || !passKey || !dataDir) return text(404, 'this relay serves no git')
5840
- const name = gm[1]
5841
- const basic = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(String(req.headers.authorization || ''))
5842
- const decoded = basic ? Buffer.from(basic[1], 'base64').toString('utf8') : ''
5843
- const pass = decoded ? verifyPass(decoded.slice(decoded.indexOf(':') + 1), passKey) : null
5844
- if (!pass || (pass.room && pass.room !== name)) { res.writeHead(401, { 'www-authenticate': 'Basic realm="Quilt"', 'content-type': 'text/plain' }); return res.end('Sign in with your Quilt pass: git asks Quilt for it (quilt git-credential).\n') }
5845
- const room = rooms.get(name) || getRoom(name)
5846
- if (!room || !room.exists || room.ended) return text(404, 'no such session')
5847
- if (!room.letIn(pass)) return text(403, NOT_LET_IN)
5848
- const service = gm[2] === '/info/refs' ? url.searchParams.get('service') : gm[2].slice(1)
5849
- if (service === 'git-receive-pack') { const a = room.httpAccess(pass); if (a && a.role === 'viewer') return text(403, 'you can only view this session, so you can\'t push') }
5850
- if (gm[2] === '/git-receive-pack') {
5851
- // A push being received: the room is not moved to another machine until it is done.
5852
- gitPushes.set(name, (gitPushes.get(name) || 0) + 1)
5853
- res.once('close', () => { const n = (gitPushes.get(name) || 1) - 1; if (n > 0) gitPushes.set(name, n); else gitPushes.delete(name) })
5854
- }
5855
- const def = viewRepoOf(room)
5856
- if (!def) { log(`[${name}] git over http: no GitHub repository (default ${room.defaultKey}: ${JSON.stringify(upstreamsOf(room)[room.defaultKey] || null)})`); return text(404, 'this session follows no GitHub repository') }
5857
- const repo = parseRepo(`https://${def.repo}`)
5858
- ;(async () => {
5859
- const m = await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })
5860
- if (!(await runGit(m.dir, ['rev-parse', '--verify', '-q', `refs/remotes/origin/${def.ref}`])).out.trim()) await room.mirrors.fetch(m.dir, def.ref, { token: await room.gitToken(repo, { access: 'read' }) }).catch(() => {})
5861
- const dir = await ensureView(dataDir, name, m.dir)
5862
- await syncViewRefs(room, dir, def.repo)
5863
- serveGit(req, res, { root: path.dirname(dir), pathInfo: `/${name}.git${gm[2]}`, query: url.search.slice(1), env: { QUILT_HOOK_URL: `http://127.0.0.1:${httpServer.address().port}/git-hook`, QUILT_HOOK_TOKEN: hookToken, QUILT_ROOM: name, QUILT_WHO: `${pass.kind}:${pass.sub}`, QUILT_WHO_NAME: String(pass.name || '') }, git: cfg.gitHttpCommand, onError: (why) => log(`[${name}] git over http (${req.method} ${gm[2]}): ${why}`) })
5864
- })().catch((err) => { log(`[${name}] git over http: ${err.message}`); if (!res.headersSent) text(500, 'the relay could not serve git right now') })
5865
- return
5866
- }
5867
- // A push into a session's git view asks here, from git's hook on this machine (relay-githttp.js, git-hook.js).
5868
- if (url.pathname === '/git-hook' && req.method === 'POST') {
5869
- const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
5870
- if (auth.length !== hookToken.length || !crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(hookToken))) return json(401, { ok: false, message: 'Quilt: not this relay\'s push' })
5871
- return readJson(req, 1024 * 1024, async (err, b) => {
5872
- try {
5873
- if (err || !b || typeof b.room !== 'string') return json(400, { ok: false, message: 'Quilt: a malformed push' })
5874
- const room = rooms.get(b.room) || getRoom(b.room)
5875
- if (!room || !room.exists) return json(404, { ok: false, message: 'Quilt: no such session' })
5876
- const updates = parseUpdates(b.updates)
5877
- if (b.phase === 'post') {
5878
- // The view's branches go back to what GitHub has (the push may have been replayed onto a newer head).
5879
- const published = (room.viewPublished && room.viewPublished.get(String(b.who))) || new Map()
5880
- const dir = viewDir(dataDir, b.room)
5881
- for (const u of updates) { const sha = published.get(u.ref); if (sha) await runGit(dir, ['update-ref', u.ref, sha]) }
5882
- const def = viewRepoOf(room)
5883
- if (def) await syncViewRefs(room, dir, def.repo)
5884
- return json(200, { ok: true })
5885
- }
5886
- const r = await receiveAgentPush(room, { who: String(b.who || ''), name: String(b.name || ''), updates, env: b.env || {} }, { log })
5887
- if (r.ok) { if (!room.viewPublished) room.viewPublished = new Map(); room.viewPublished.set(String(b.who), r.published) }
5888
- return json(200, { ok: r.ok, message: r.message })
5889
- } catch (e) { log(`[${b && b.room}] git push: ${e.message}`); return json(500, { ok: false, message: `Quilt: the relay couldn't take the push (${e.message})` }) }
5890
- })
5891
- }
5892
4617
  if (url.pathname === '/mcp') {
5893
4618
  // Hosted agents (and anyone with a pass): session tools over HTTP, no app needed.
5894
4619
  if (!passKey) return text(404, 'this relay has sign-in off; hosted agents need it on')
@@ -6157,13 +4882,12 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
6157
4882
  ws.isAlive = false
6158
4883
  ws.ping()
6159
4884
  }
6160
- for (const room of rooms.values()) if (!room.ended) { room.sweepClaims(); room.sweepBoxes(); room.trimMergedBranches(); room.pruneBranches(); room.findMergedBranches().catch(() => {}) }
4885
+ for (const room of rooms.values()) if (!room.ended) { room.sweepClaims(); room.sweepBoxes(); room.pruneBranches() }
6161
4886
  if (sweepHosted) sweepHosted()
6162
4887
  }, 30000)
6163
4888
 
6164
4889
  // Delete rooms nobody has opened for a while (hosted relays shouldn't grow forever).
6165
4890
  const sweep = () => {
6166
- mirrors.prune()
6167
4891
  if (!dataDir || !cfg.roomTtlDays) return
6168
4892
  const cutoff = Date.now() - cfg.roomTtlDays * DAY
6169
4893
  let removed = 0
@@ -6191,33 +4915,16 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
6191
4915
  sweep()
6192
4916
  const sweeper = setInterval(sweep, 6 * 60 * 60 * 1000)
6193
4917
  sweeper.unref()
6194
- // Branches only hosted agents work on, with no folder online: the relay looks for new commits on GitHub itself.
6195
- const upstreamTicker = cfg.upstreamAuto && cfg.upstreamCheckMs > 0
6196
- ? setInterval(() => { for (const room of rooms.values()) if (!room.ended) { room.checkUpstreams().catch((err) => log(`[${room.name}] could not look for new commits: ${err.message}`)) } }, Math.min(cfg.gitSync ? 20 * 1000 : 60 * 1000, cfg.upstreamCheckMs)) // with the mirror, often enough that each branch's minute look isn't a tick late
6197
- : null
6198
- upstreamTicker?.unref()
6199
- // Live-sync leases renewed, and let go of for sessions that ended or sat idle for a day (relay-lease.js).
6200
- const leaseTicker = cfg.gitSync ? setInterval(() => {
4918
+ // The relay's leases renewed, and let go of for sessions that ended (relay-lease.js).
4919
+ const leaseTicker = setInterval(() => {
6201
4920
  // A migration's lease (Room.withMigrationLease) is let go of once no migration here holds it (after a crash, at the next tick).
6202
4921
  leases.tick({ ended: (name) => { if (name.startsWith(MIGRATION_LEASE)) return !migrating.has(name); const r = rooms.get(name); return !!(r && r.ended) } }).then((lost) => {
6203
4922
  for (const l of lost) { const r = rooms.get(l.room); if (r && !r.ended) { r.log(`[${r.name}] lost the live-sync lease of ${l.branch} to ${l.to || 'nobody (lapsed)'}`); r.broadcastBranches() } }
6204
4923
  }).catch((err) => log(`lease tick: ${err.message}`))
6205
- }, cfg.leaseHeartbeatMs || LEASE_HEARTBEAT_MS) : null
6206
- leaseTicker?.unref()
6207
- // Agents with work not in git on GitHub are reminded to commit it (relay-commit.js), about every ten minutes.
6208
- const remindTicker = cfg.upstreamAuto
6209
- ? setInterval(() => {
6210
- for (const room of rooms.values()) {
6211
- if (room.ended) continue
6212
- for (const key of new Set([...Object.keys(upstreamsOf(room)), ...Object.keys(room.meta.branches || {})].map((k) => room.resolveKey(k)))) remindUnpublished(room, key, { fetch: room.githubFetch }).catch((err) => log(`[${room.name}] ${key}: could not remind about uncommitted work: ${err.message}`))
6213
- reviewPulls(room, { fetch: room.githubFetch, log }).catch((err) => log(`[${room.name}] could not look at the tasks' pull requests: ${err.message}`))
6214
- }
6215
- }, 10 * 60 * 1000)
6216
- : null
6217
- remindTicker?.unref()
6218
-
4924
+ }, cfg.leaseHeartbeatMs || LEASE_HEARTBEAT_MS)
4925
+ leaseTicker.unref()
6219
4926
  return new Promise((resolve, reject) => {
6220
- httpServer.once('error', (err) => { clearInterval(heartbeat); clearInterval(sweeper); clearInterval(upstreamTicker); clearInterval(remindTicker); features?.stop(); reject(err) })
4927
+ httpServer.once('error', (err) => { clearInterval(heartbeat); clearInterval(sweeper); clearInterval(leaseTicker); features?.stop(); reject(err) })
6221
4928
  httpServer.listen(port, host, () => {
6222
4929
  const actualPort = httpServer.address().port
6223
4930
  resolve({
@@ -6228,13 +4935,10 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
6228
4935
  store, // exposed for tests
6229
4936
  presence, // exposed for tests
6230
4937
  hosted, // exposed for tests
6231
- mirrors, // the git mirrors (exposed for tests)
6232
4938
  leases, // the live-sync leases (exposed for tests)
6233
4939
  sweepHosted: () => sweepHosted && sweepHosted(), // exposed for tests: ends quiet hosted agents' visits now
6234
4940
  features, // exposed for tests
6235
4941
  sweep,
6236
- // exposed for tests: the relay's look at GitHub for a loaded room now ({ force }: every branch it looks after, not only those due)
6237
- checkUpstreams: (name, o) => { const room = rooms.get(name); return room ? room.checkUpstreams(o) : Promise.resolve([]) },
6238
4942
  cluster, // exposed for tests
6239
4943
  close: async () => {
6240
4944
  clearInterval(reportTicker)
@@ -6244,8 +4948,6 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
6244
4948
  clearInterval(leaseTicker)
6245
4949
  clearInterval(heartbeat)
6246
4950
  clearInterval(sweeper)
6247
- clearInterval(upstreamTicker)
6248
- clearInterval(remindTicker)
6249
4951
  features?.stop()
6250
4952
  updates.stop()
6251
4953
  // A join or a denial in the last moments before a deploy is kept.