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.
- package/RELEASES.md +10 -0
- package/bin/quilt.js +7 -143
- package/package.json +1 -1
- package/src/account.js +0 -29
- package/src/api/memory-store.js +0 -29
- package/src/api/routes/teams.js +0 -23
- package/src/api/server.js +5 -19
- package/src/api/supabase-store.js +0 -52
- package/src/branch-view.js +6 -131
- package/src/control.js +2 -33
- package/src/duties.js +1 -1
- package/src/github-commit.js +3 -315
- package/src/mcp.js +8 -157
- package/src/relay-ask.js +3 -66
- package/src/relay-cluster.js +3 -5
- package/src/relay-mcp.js +6 -418
- package/src/relay-prune.js +6 -145
- package/src/relay-turn-sync.js +1 -1
- package/src/server.js +33 -1331
- package/src/session-follow.js +18 -5
- package/src/session.js +58 -3526
- package/src/status.js +2 -58
- package/src/turn-sync.js +15 -2
- package/src/ui/changes.js +5 -14
- package/src/ui/chat.js +0 -1
- package/src/ui/common.js +0 -2
- package/src/ui/git-rule.js +2 -30
- package/src/ui/home.js +3 -76
- package/src/ui/session.js +34 -528
- package/src/ui/tree.js +2 -8
- package/src/ui/workspaces.js +2 -122
- package/src/ui-server.js +1 -38
- package/src/api/routes/github.js +0 -380
- package/src/branches.js +0 -194
- package/src/clash.js +0 -128
- package/src/commit-request.js +0 -59
- package/src/commit.js +0 -213
- package/src/git-hook.js +0 -48
- package/src/gitignore.js +0 -49
- package/src/gitstate.js +0 -1069
- package/src/relay-ci.js +0 -111
- package/src/relay-clash.js +0 -214
- package/src/relay-commit.js +0 -923
- package/src/relay-filekey.js +0 -46
- package/src/relay-git.js +0 -274
- package/src/relay-githttp.js +0 -166
- package/src/relay-github.js +0 -109
- package/src/relay-outbound.js +0 -1295
- package/src/relay-repo-moves.js +0 -43
- package/src/relay-sync.js +0 -795
- package/src/relay-upstream.js +0 -889
- package/src/sync-words.js +0 -42
- 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
|
|
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,
|
|
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
|
|
73
|
-
import { DIFF,
|
|
74
|
-
import { inactiveBranches,
|
|
75
|
-
import {
|
|
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
|
-
//
|
|
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
|
|
986
|
-
if (origin &&
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
3024
|
-
const mainsOld = async (key, files
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
6195
|
-
const
|
|
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)
|
|
6206
|
-
leaseTicker
|
|
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(
|
|
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.
|