heyquilt 0.0.1 → 0.3.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +133 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1219 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
@@ -0,0 +1,111 @@
1
+ // CI on GitHub becomes work on the board: checks failing on a branch the session follows open one task,
2
+ // "CI failing on <branch>", for the member whose commit it is (when they are in the session); a newer commit
3
+ // failing too is noted on it; checks passing again move it to Done. One task per branch (its id comes from the
4
+ // branch), so a run reported twice never adds a second.
5
+ import crypto from 'node:crypto'
6
+ import { readTasks, addTask, updateTask } from './tasks.js'
7
+ import { addComment } from './task-comments.js'
8
+ import { githubWriter } from './github-commit.js'
9
+ import { credentialsFor } from './relay-commit.js'
10
+ import { upstreamsOf, parseRepo } from './relay-upstream.js'
11
+ import { memberForAuthor, everyone } from './relay-clash.js'
12
+ import { runGit } from './relay-git.js'
13
+ import { RELAY_BY } from './branches.js'
14
+
15
+ const ORIGIN = 'relay-publish'
16
+ // What GitHub calls a check suite that failed (check_suite.conclusion).
17
+ const FAILED = new Set(['failure', 'timed_out', 'action_required', 'startup_failure'])
18
+
19
+ /** The task id for CI on `branch` of this session: the same every time. */
20
+ export const ciTaskId = (room, branch) => crypto.createHash('sha256').update(`ci\0${room.name}\0${branch}`).digest('hex').slice(0, 16)
21
+
22
+ /** Who made commit `sha` (from the relay's mirror): the member it maps to, or ''. */
23
+ async function authorOf (room, rec, sha) {
24
+ if (!room.mirrors) return ''
25
+ try {
26
+ const repo = parseRepo(`https://${rec.repo}`)
27
+ const dir = (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
28
+ const r = await runGit(dir, ['log', '-1', '--format=%an%x1f%ae', sha])
29
+ if (!r.ok) return ''
30
+ const [name, email] = r.out.trim().split('\x1f')
31
+ // By their noreply address, else by name: a member on record, or one connected now.
32
+ const online = room.names ? [...room.names.values()] : []
33
+ return memberForAuthor(room, { name, email }) || everyone(room).find((m) => m.name === name)?.name || online.find((n) => n === name) || ''
34
+ } catch { return '' }
35
+ }
36
+
37
+ /**
38
+ * A check suite on `repo` finished at commit `sha` of `branch` with `conclusion`. When the session follows that
39
+ * branch: failing opens (or reopens, or notes) its CI task; passing moves an open one to Done. Returns what it did
40
+ * ('opened', 'noted', 'closed') or null. Never throws.
41
+ */
42
+ export function noteCiResult (room, result, opts = {}) {
43
+ // One at a time per branch, in the order GitHub's results arrive: a failure takes longer to note (GitHub is asked
44
+ // which checks failed), and a pass right after it must not be noted first.
45
+ if (!room.ciQueue) room.ciQueue = new Map()
46
+ const k = String(result && result.branch)
47
+ const next = (room.ciQueue.get(k) || Promise.resolve()).then(() => noteCiResultNow(room, result, opts))
48
+ room.ciQueue.set(k, next.catch(() => null))
49
+ return next
50
+ }
51
+
52
+ /** Whether commit `older` is an ancestor of `newer` in the relay's mirror of `rec`'s repository (false when it can't say). */
53
+ async function before (room, rec, older, newer) {
54
+ try {
55
+ const repo = parseRepo(`https://${rec.repo}`)
56
+ const dir = (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
57
+ return (await runGit(dir, ['merge-base', '--is-ancestor', older, newer])).ok
58
+ } catch { return false }
59
+ }
60
+
61
+ async function noteCiResultNow (room, { repo, sha, branch, conclusion }, { fetch = globalThis.fetch, log = () => {} } = {}) {
62
+ try {
63
+ const want = String(repo).toLowerCase().replace(/^github\.com\//, '')
64
+ const rec = Object.values(upstreamsOf(room)).find((r) => r && r.repo && r.ref === branch && String(r.repo).toLowerCase().replace(/^github\.com\//, '') === want)
65
+ if (!rec || !/^[0-9a-f]{40}$/.test(sha || '')) return null
66
+ const failed = FAILED.has(String(conclusion))
67
+ if (!failed && conclusion !== 'success') return null
68
+ // A failure on an older commit than one that passed since (GitHub's results can come out of order) is old news.
69
+ if (!room.ciPassed) room.ciPassed = new Map()
70
+ const passed = room.ciPassed.get(branch)
71
+ if (!failed) room.ciPassed.set(branch, sha)
72
+ else if (passed && passed !== sha && room.mirrors && await before(room, rec, sha, passed)) return null
73
+ const map = room.doc.getMap('tasks')
74
+ const id = ciTaskId(room, branch)
75
+ const task = readTasks(map).find((t) => t.id === id)
76
+ const open = task && !task.archived && task.column !== 'done'
77
+ const comment = (text) => addComment(room.doc, room.doc.getMap('taskComments'), readTasks(map), { taskId: id, by: RELAY_BY, text }, ORIGIN)
78
+ if (!failed) {
79
+ if (!open) return null
80
+ comment(`CI passes again on ${branch} at ${sha.slice(0, 7)}.`)
81
+ updateTask(room.doc, map, { id, column: 'done' }, ORIGIN)
82
+ return 'closed'
83
+ }
84
+ // Which checks failed, when GitHub says (a failing suite is enough to act on).
85
+ let names = []
86
+ try {
87
+ const r = parseRepo(`https://${rec.repo}`)
88
+ const c = await githubWriter({ fetch, token: await credentialsFor(room, r), owner: r.owner, name: r.name }).checks(sha)
89
+ names = c.failing || []
90
+ } catch (err) { log(`[${room.name}] ${branch}: could not read which checks failed on ${sha.slice(0, 7)}: ${err.message}`) }
91
+ const what = names.length ? names.slice(0, 5).join(', ') : 'checks'
92
+ // The relay shut down while GitHub was asked: the room notes it when it is back (server.js runCi).
93
+ if (room.destroyed) return null
94
+ const url = `https://${rec.repo}/commit/${sha}`
95
+ if (open) {
96
+ comment(`Still failing at ${sha.slice(0, 7)}: ${what}. ${url}`)
97
+ return 'noted'
98
+ }
99
+ const who = await authorOf(room, rec, sha)
100
+ if (room.destroyed) return null
101
+ const title = `CI failing on ${branch}: ${what}`
102
+ if (task) updateTask(room.doc, map, { id, title, column: 'todo', archived: false, ...(who ? { assignee: who } : {}) }, ORIGIN)
103
+ else addTask(room.doc, map, { id, title, by: RELAY_BY, assignee: who, column: 'todo' }, ORIGIN)
104
+ comment(`GitHub's checks failed on ${branch} at ${sha.slice(0, 7)}: ${what}. ${url}${who ? ` The commit is ${who}'s.` : ''}`)
105
+ log(`[${room.name}] ${branch}: CI failing at ${sha.slice(0, 7)} (${what}): task ${id}${who ? ` for ${who}` : ''}`)
106
+ return 'opened'
107
+ } catch (err) {
108
+ log(`[${room.name}] ${branch}: could not note CI on ${String(sha).slice(0, 7)}: ${err.message}`)
109
+ return null
110
+ }
111
+ }
@@ -0,0 +1,214 @@
1
+ // Automatic merges on the relay (docs/superpowers/specs/2026-10-18-automatic-merges.md). Where
2
+ // GitHub's commits and the session's work changed the same file, the relay combines them itself
3
+ // (upstream.js combine: lines only one side changed are kept, lines both changed go to the newer
4
+ // edit, an edit beats a deletion, a binary is the newer version) and the file lands at once: nobody
5
+ // is asked to merge, no task, nothing held. What lost is kept (the file's history; the session's
6
+ // version as a blob in the mirror, kept by its keep refs for RESOLVED_KEEP_MS) and whoever wrote it
7
+ // is told: a direct message from the relay to the member whose lines lost, or a line in the
8
+ // activity log for GitHub's. GitHub's checks failing on a commit that carried a merge tell both
9
+ // parties; the file is never held for it.
10
+ //
11
+ // quilt_clash stays registered for a release (older guides call it) and answers that there is
12
+ // nothing to do. Quilt never runs an AI itself.
13
+ import { syncStateOf, runGit } from './relay-git.js'
14
+ import { upstreamsOf, RELAY_ORIGIN } from './relay-upstream.js'
15
+ import { readTasks, updateTask } from './tasks.js'
16
+ import { RELAY_BY } from './branches.js'
17
+ import { slug } from './github-commit.js'
18
+ import { updateMerge } from './merges.js'
19
+
20
+ export const RESOLVED_KEEP_MS = 30 * 24 * 60 * 60 * 1000
21
+ /** quilt_clash's actions, still accepted (each answers that Quilt merges on its own). */
22
+ export const CLASH_ACTIONS = ['propose', 'accept', 'keep_session', 'keep_github', 'keep_offline', 'revert', 'status']
23
+ /** The most replaced lines a notice lists. */
24
+ export const LOST_LINES_SHOWN = 12
25
+ export const NOTHING_TO_MERGE = 'Quilt merges on its own now: where GitHub\'s commits and the session changed the same lines, the newer edit is kept, the other lines stay in the file\'s history (quilt_history), and whoever wrote them is told. Nothing to do.'
26
+
27
+ const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
28
+
29
+ /** Everyone in the session: its members and its owner (who may not be in the members list): [{ id, name, kind, github? }]. */
30
+ export function everyone (room) {
31
+ const out = Object.entries(room.meta.members || {}).filter(([, m]) => m && m.name).map(([id, m]) => ({ id, ...m }))
32
+ if (room.meta.ownerName && !out.some((m) => m.name === room.meta.ownerName)) out.push({ id: room.meta.ownerSub || '', name: room.meta.ownerName, kind: 'human', ...(room.meta.ownerGithub ? { github: room.meta.ownerGithub } : {}) })
33
+ return out
34
+ }
35
+
36
+ /** A git author as a member: by the agents' or people's noreply address, or the GitHub account a person linked. */
37
+ export function memberForAuthor (room, { name = '', email = '' }) {
38
+ const e = String(email || '').toLowerCase()
39
+ const members = everyone(room)
40
+ let m
41
+ if ((m = /^([a-z0-9-]+)@agents\.noreply\.heyquilt\.com$/.exec(e))) return members.find((x) => x.kind === 'agent' && slug(x.name, 40) === m[1])?.name || null
42
+ if ((m = /^([a-z0-9-]+)@people\.noreply\.heyquilt\.com$/.exec(e))) return members.find((x) => x.kind !== 'agent' && slug(x.name, 40) === m[1])?.name || null
43
+ if ((m = /^(?:\d+\+)?([a-z0-9-]+)@users\.noreply\.github\.com$/.exec(e))) return members.find((x) => x.github && String(x.github.login).toLowerCase() === m[1])?.name || null
44
+ return null
45
+ }
46
+
47
+ /** The commits between `base` and `head` that changed `rel`, newest first: [{ sha, name, email, ts, subject }] (ts: commit time). */
48
+ export async function upstreamCommits (dir, base, head, rel) {
49
+ const r = await runGit(dir, ['log', '--format=%H%x1f%an%x1f%ae%x1f%ct%x1f%s', `${base}..${head}`, '--', rel])
50
+ if (!r.ok) return []
51
+ return r.out.split('\n').filter(Boolean).map((l) => { const [sha, name, email, ct, subject] = l.split('\x1f'); return { sha, name, email, ts: Number(ct) * 1000, subject } })
52
+ }
53
+
54
+ /** Stores `buf` (a Buffer or text) as a blob in the mirror: its sha, or null. */
55
+ export async function storeBlob (dir, buf) {
56
+ const r = await runGit(dir, ['hash-object', '-w', '--stdin'], { input: Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf), 'utf8') })
57
+ return r.ok ? r.out.trim() : null
58
+ }
59
+
60
+ const dm = (room, to, text, kind = 'nudge') => { if (to && room.postChat) room.postChat({ by: RELAY_BY, to, kind, text }) }
61
+ const activity = (room, entry) => {
62
+ if (!room.activity || !room.doc) return
63
+ room.doc.transact(() => {
64
+ room.activity.push([entry])
65
+ if (room.activity.length > 300) room.activity.delete(0, room.activity.length - 300)
66
+ }, RELAY_ORIGIN)
67
+ }
68
+
69
+ /** The replaced lines of `lost` (mergeAuto's), as a list for a notice: at most LOST_LINES_SHOWN. */
70
+ export function lostLines (lost) {
71
+ const all = lost.flatMap((l) => l.lines)
72
+ const shown = all.slice(0, LOST_LINES_SHOWN).map((l) => `- ${clip(l, 200)}`)
73
+ if (all.length > shown.length) shown.push(`- and ${all.length - shown.length} more`)
74
+ return shown.join('\n')
75
+ }
76
+
77
+ /**
78
+ * The relay just combined `path` on branch `key` itself (upstream.js combine): whoever's lines lost
79
+ * is told, once. `lost`: combine's; `session`: { name, ts } the member who last changed it in the
80
+ * session; `github`: { name, member?, sha } the newest commit of GitHub's that changed it; `upstream`
81
+ * the upstream's name (origin/main). The session's lines lost: a direct message to that member, with
82
+ * the lines; GitHub's: a line in the activity log. `keptIn`: where the losing lines are kept. A notice, never a task.
83
+ */
84
+ export function tellAutoMerge (room, key, { path: rel, lost = [], session = null, github = null, upstream = 'GitHub', head = '', deleted = null, keptIn = 'the file\'s history (quilt_history)', now = Date.now() }) {
85
+ const at = head ? ` ${head.slice(0, 7)}` : ''
86
+ const ghName = github ? (github.member || github.name || upstream) : upstream
87
+ if (deleted === 'theirs') activity(room, { by: RELAY_BY, path: rel, kind: 'merged', detail: `${upstream}${at} deleted it, but the session changed it: it stays`, branch: key, ts: now })
88
+ if (deleted === 'ours' && session && session.name) dm(room, session.name, `${rel} on ${key}: it was deleted in the session, but ${ghName} changed it on GitHub (${upstream}${at}), so it is back with that change. Delete it again if it should go.`)
89
+ const ours = lost.filter((l) => l.side === 'ours')
90
+ const theirs = lost.filter((l) => l.side === 'theirs')
91
+ if (ours.length && session && session.name) {
92
+ const binary = ours.some((l) => l.binary)
93
+ dm(room, session.name, binary
94
+ ? `Quilt merged ${upstream}${at} into ${key}: you and ${ghName} both changed ${rel}, a binary file, and ${ghName}'s newer version stays. Yours is kept in ${keptIn}.`
95
+ : `Quilt merged ${upstream}${at} into ${rel} on ${key}: where you and ${ghName} both changed the same lines, ${ghName}'s newer edit stays. The lines of yours it replaced are in ${keptIn}:\n${lostLines(ours)}`)
96
+ }
97
+ if (theirs.length) {
98
+ const n = theirs.reduce((k, l) => k + (l.binary ? 1 : l.lines.length), 0)
99
+ activity(room, { by: RELAY_BY, path: rel, kind: 'merged', detail: theirs.some((l) => l.binary) ? `kept the session's newer version over ${upstream}${at}'s` : `kept the session's newer lines over ${n} line${n === 1 ? '' : 's'} of ${upstream}${at}`, branch: key, ts: now })
100
+ }
101
+ }
102
+
103
+ /** A held entry from an older relay (a clash, failing checks): its task Done and its merge record closed, as Quilt merged it itself. */
104
+ export function closeOldClash (room, key, h, { how = 'merged by Quilt', now = Date.now() } = {}) {
105
+ if (h.task) {
106
+ try {
107
+ const t = readTasks(room.tasks || room.doc.getMap('tasks')).find((x) => x.id === h.task)
108
+ if (t && t.column !== 'done') updateTask(room.doc, room.tasks || room.doc.getMap('tasks'), { id: h.task, column: 'done', verified: `${h.path}: ${how}. Quilt merges on its own now.` }, RELAY_ORIGIN)
109
+ } catch {}
110
+ }
111
+ if (h.mergeId) { try { const e = room.branchDoc(key); updateMerge(e.doc, e.doc.getMap('merges'), h.mergeId, { state: 'done', how: 'mine', resolvedBy: 'Quilt' }, RELAY_ORIGIN) } catch {} }
112
+ noteResolved(room, key, { path: h.path, kind: h.kind || 'upstream', base: h.base || null, theirs: h.theirs || null, oursBlob: h.oursBlob || null, otherBlob: h.otherBlob || null, baseBlob: h.baseBlob || null, how, at: now })
113
+ }
114
+
115
+ /** Keeps a merge's versions on record (state.resolved) for RESOLVED_KEEP_MS, so the mirror keeps their objects (relay-sync.js keptObjects). */
116
+ export function noteResolved (room, key, entry) {
117
+ const state = syncStateOf(room, key)
118
+ if (!Array.isArray(state.resolved)) state.resolved = []
119
+ const now = entry.at || Date.now()
120
+ state.resolved.push({ ...entry, at: now })
121
+ state.resolved = state.resolved.filter((r) => now - r.at < RESOLVED_KEEP_MS).slice(-100)
122
+ }
123
+
124
+ /**
125
+ * quilt_clash, kept for older guides: every action answers that Quilt merges on its own and there
126
+ * is nothing to do. Resolves { ok: true, text }; never throws.
127
+ */
128
+ export async function clashAction (room, { key = '', action = '' } = {}) {
129
+ if (action === 'status') return { ok: true, text: `No clashes${key ? ` on ${key}` : ''}. ${NOTHING_TO_MERGE}` }
130
+ return { ok: true, text: NOTHING_TO_MERGE }
131
+ }
132
+
133
+ export const CLASH_DESCRIPTION = 'No longer needed: Quilt merges on its own. Where GitHub\'s commits and the session changed the same lines, the newer edit is kept, the other lines stay in the file\'s history, and whoever wrote them is told. Every action answers that there is nothing to do.'
134
+ export const clashSchema = (z) => ({
135
+ action: z.enum(CLASH_ACTIONS).describe('Any: there is nothing to do'),
136
+ path: z.string().max(1024).optional().describe('Ignored'),
137
+ content: z.string().max(1024 * 1024).optional().describe('Ignored'),
138
+ checks: z.object({ result: z.enum(['passed', 'failed', 'none']), command: z.string().max(300).optional() }).optional().describe('Ignored'),
139
+ force: z.boolean().optional().describe('Ignored')
140
+ })
141
+
142
+ /**
143
+ * A merged file (combined on the way in) is on record until it is pushed: then the commit it went
144
+ * in is, with both parties, so failing checks on that commit can tell them (noteMergeChecks).
145
+ */
146
+ export function noteMerged (room, key, rel, parties, { now = Date.now() } = {}) {
147
+ const state = syncStateOf(room, key)
148
+ if (!state.merged || typeof state.merged !== 'object') state.merged = {}
149
+ state.merged[rel] = { parties, at: now }
150
+ }
151
+
152
+ /** Called by the outbound push: the merged files that went in `sha`. */
153
+ export function notePushedMerges (room, key, sha, changed) {
154
+ const state = syncStateOf(room, key)
155
+ if (!state.merged) return
156
+ const files = changed.filter((rel) => state.merged[rel])
157
+ if (!files.length) return
158
+ if (!state.mergeCommits || typeof state.mergeCommits !== 'object') state.mergeCommits = {}
159
+ state.mergeCommits[sha] = { files, parties: files.map((rel) => state.merged[rel].parties), at: Date.now() }
160
+ for (const rel of files) delete state.merged[rel]
161
+ const keys = Object.keys(state.mergeCommits)
162
+ for (const k of keys.slice(0, Math.max(0, keys.length - 20))) delete state.mergeCommits[k]
163
+ }
164
+
165
+ /** The failing check runs' annotations on `sha` (REST metadata): [{ path, line }], or [] when GitHub doesn't say. */
166
+ async function failingLines (room, key, sha) {
167
+ const rec = upstreamsOf(room)[key]
168
+ try {
169
+ const [owner, name] = String(rec.repo).replace(/^github\.com\//, '').split('/')
170
+ const token = await room.gitToken({ owner, name, slug: rec.repo })
171
+ const f = room.githubFetch || globalThis.fetch
172
+ const headers = { 'user-agent': 'quilt-relay', 'x-github-api-version': '2022-11-28', accept: 'application/vnd.github+json', ...(token ? { authorization: `Bearer ${token}` } : {}) }
173
+ const runs = await (await f(`https://api.github.com/repos/${owner}/${name}/commits/${sha}/check-runs?per_page=50`, { headers })).json()
174
+ const out = []
175
+ for (const r of (runs && runs.check_runs) || []) {
176
+ if (r.conclusion !== 'failure' || !r.id) continue
177
+ const ann = await (await f(`https://api.github.com/repos/${owner}/${name}/check-runs/${r.id}/annotations?per_page=50`, { headers })).json()
178
+ for (const a of Array.isArray(ann) ? ann : []) if (a && a.path) out.push({ path: a.path, line: a.start_line || 0, message: String(a.message || '').slice(0, 200) })
179
+ }
180
+ return out
181
+ } catch { return [] }
182
+ }
183
+
184
+ /**
185
+ * GitHub's checks on `sha` (a check_suite) finished. Failing on a commit that carried merged files:
186
+ * both parties of those merges are told, once, with the failing lines GitHub names. Nothing is held:
187
+ * the file keeps syncing, and a fix goes like any edit. Files an older relay held for failing checks
188
+ * are let go of. Returns what changed: [{ path, told: [names] }, { path, released: true }].
189
+ */
190
+ export async function noteMergeChecks (room, key, { sha, conclusion }, { now = Date.now() } = {}) {
191
+ const state = syncStateOf(room, key)
192
+ const out = []
193
+ for (const h of state.held.filter((x) => x.why === 'checks')) {
194
+ state.held = state.held.filter((x) => x !== h)
195
+ closeOldClash(room, key, h, { how: 'let go of: a file is no longer held for failing checks', now })
196
+ if (room.outbound) room.outbound.note(key, h.path)
197
+ out.push({ path: h.path, released: true })
198
+ }
199
+ if (conclusion !== 'failure' && conclusion !== 'timed_out') return out
200
+ const mc = state.mergeCommits && state.mergeCommits[sha]
201
+ if (!mc || mc.told) return out
202
+ mc.told = now
203
+ const lines = await failingLines(room, key, sha)
204
+ const named = mc.files.filter((rel) => lines.some((l) => l.path === rel))
205
+ const files = named.length ? named : mc.files
206
+ for (const rel of files) {
207
+ const parties = mc.parties[mc.files.indexOf(rel)] || {}
208
+ const names = [...new Set([parties.session && parties.session.name, parties.github && (parties.github.member || null)].filter(Boolean))]
209
+ const where = lines.filter((l) => l.path === rel).slice(0, 5).map((l) => `\n- ${rel}:${l.line} ${l.message}`).join('')
210
+ for (const n of names) dm(room, n, `GitHub's checks failed on ${sha.slice(0, 7)}, which carried Quilt's merge of ${rel} on ${key} (your edit and ${names.find((x) => x !== n) || 'one from GitHub'}'s). It is not held: a fix goes in like any edit.${where}`)
211
+ out.push({ path: rel, told: names })
212
+ }
213
+ return out
214
+ }