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