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,597 @@
|
|
|
1
|
+
// Merge conflicts, automatically (plan §7.3, step 3 onwards; addendum 16.2, 16.3, 16.8). A file
|
|
2
|
+
// whose lines GitHub and the session both changed is held (relay-sync.js): inbound doesn't write
|
|
3
|
+
// it, outbound keeps the remote version. The two parties resolve it together:
|
|
4
|
+
//
|
|
5
|
+
// - Who: the session side is the member whose edit it is (the branch's history); the GitHub side
|
|
6
|
+
// is the upstream commit's author, a member when their linked GitHub account or noreply address
|
|
7
|
+
// says so. The merger is the party online who made the more recent of the two edits (with no
|
|
8
|
+
// party online, any agent that may change the file); the reviewer is the other party.
|
|
9
|
+
// - Agreement first: both are told, in a direct message, with the base, both versions and the
|
|
10
|
+
// upstream commit messages on the clash task.
|
|
11
|
+
// - Merge: the merger proposes the merged file (quilt_clash propose, never markers) with the checks
|
|
12
|
+
// it ran on exactly that content. The proposal is kept apart from the live file (a blob in the
|
|
13
|
+
// mirror), so nothing half-merged reaches any node; agents can't write a held file at all. The
|
|
14
|
+
// reviewer accepts (quilt_clash accept), and only then is the file written and pushed. A
|
|
15
|
+
// reviewer who doesn't answer in 15 minutes is told it went through, when the owner's policy
|
|
16
|
+
// says so (the default); a reviewer who is offline, outside Quilt or the same member isn't waited for.
|
|
17
|
+
// - Escalation: a merger with no proposal after 15 minutes hands it to the reviewer, then to any
|
|
18
|
+
// agent; with nobody to take it, the people in the session are told. A person's save of the
|
|
19
|
+
// file without markers settles it; whoever holds it can keep one side in one action.
|
|
20
|
+
// - Recovery: each clash keeps its own base, ours (a blob) and theirs (commits) under the mirror's
|
|
21
|
+
// keep refs while it is open and for RESOLVED_KEEP_MS after.
|
|
22
|
+
//
|
|
23
|
+
// Quilt never runs an AI itself: the merger and reviewer are members working a task.
|
|
24
|
+
import crypto from 'node:crypto'
|
|
25
|
+
import * as Y from 'yjs'
|
|
26
|
+
import { syncStateOf, runGit } from './relay-git.js'
|
|
27
|
+
import { upstreamsOf, docKey, hasMarkers, fingerprint, RELAY_ORIGIN } from './relay-upstream.js'
|
|
28
|
+
import { applyTextDiff } from './textdiff.js'
|
|
29
|
+
import { HistoryLog, lineDiff } from './history.js'
|
|
30
|
+
import { readTasks, addTask, updateTask } from './tasks.js'
|
|
31
|
+
import { addComment, MAX_COMMENT } from './task-comments.js'
|
|
32
|
+
import { changeRefusal } from './session-access.js'
|
|
33
|
+
import { RELAY_BY, isRelay } from './branches.js'
|
|
34
|
+
import { slug } from './github-commit.js'
|
|
35
|
+
import { updateMerge } from './merges.js'
|
|
36
|
+
import { isSafeRelPath } from './pathrules.js'
|
|
37
|
+
|
|
38
|
+
export const CLASH_REVIEW_MS = 15 * 60 * 1000
|
|
39
|
+
export const CLASH_MERGER_IDLE_MS = 15 * 60 * 1000
|
|
40
|
+
export const RESOLVED_KEEP_MS = 30 * 24 * 60 * 60 * 1000
|
|
41
|
+
export const CLASH_ACTIONS = ['propose', 'accept', 'keep_session', 'keep_github', 'keep_offline', 'revert', 'status']
|
|
42
|
+
|
|
43
|
+
const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex')
|
|
44
|
+
const isText = (k) => typeof k === 'string' && !k.startsWith('bin:')
|
|
45
|
+
const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
|
|
46
|
+
|
|
47
|
+
/** Everyone in the session: its members and its owner (who may not be in the members list): [{ id, name, kind, github? }]. */
|
|
48
|
+
export function everyone (room) {
|
|
49
|
+
const out = Object.entries(room.meta.members || {}).filter(([, m]) => m && m.name).map(([id, m]) => ({ id, ...m }))
|
|
50
|
+
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 } : {}) })
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether `name` (a member, a hosted agent, or a person's AI session) is online in the room now. */
|
|
55
|
+
export function isOnline (room, name) {
|
|
56
|
+
if (!name) return false
|
|
57
|
+
for (const ws of (room.conns ? room.conns.keys() : [])) if (room.names && room.names.get(ws) === name) return true
|
|
58
|
+
if (room.awareness) for (const st of room.awareness.getStates().values()) if (st && Array.isArray(st.personas) && st.personas.some((p) => p && p.name === name)) return true
|
|
59
|
+
if (room.hostedOnline) for (const h of room.hostedOnline()) if (h.name === name) return true
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A git author as a member: by the agents' or people's noreply address, or the GitHub account a person linked. */
|
|
64
|
+
export function memberForAuthor (room, { name = '', email = '' }) {
|
|
65
|
+
const e = String(email || '').toLowerCase()
|
|
66
|
+
const members = everyone(room)
|
|
67
|
+
let m
|
|
68
|
+
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
|
|
69
|
+
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
|
|
70
|
+
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
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The commits between `base` and `head` that changed `rel`, newest first: [{ sha, name, email, ts, subject }]. */
|
|
75
|
+
export async function upstreamCommits (dir, base, head, rel) {
|
|
76
|
+
const r = await runGit(dir, ['log', '--format=%H%x1f%an%x1f%ae%x1f%ct%x1f%s', `${base}..${head}`, '--', rel])
|
|
77
|
+
if (!r.ok) return []
|
|
78
|
+
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 } })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The session's side: the member who last changed `rel` on the branch (not the relay, not a pull). */
|
|
82
|
+
function sessionParty (e, rel, { except = null } = {}) {
|
|
83
|
+
// By time, not position: a save soon after the same person's last one folds into that entry where it was (history.js).
|
|
84
|
+
let best = null
|
|
85
|
+
for (const h of e.doc.getArray('history').toArray()) {
|
|
86
|
+
if (h && h.path === rel && !h.pulled && !h.seed && h.by && !isRelay(h.by) && h.by !== except && (!best || (h.ts || 0) >= best.ts)) best = { name: h.by, ts: h.ts || 0 }
|
|
87
|
+
}
|
|
88
|
+
return best
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Agents online that may change `rel` on branch `key`: hosted ones on the branch first, then connected agents. */
|
|
92
|
+
function agentsFor (room, key, rel) {
|
|
93
|
+
const out = []
|
|
94
|
+
const ok = (a) => a && a.role && a.role !== 'viewer' && !changeRefusal(a, rel)
|
|
95
|
+
for (const h of (room.hostedOnline ? room.hostedOnline() : [])) {
|
|
96
|
+
const m = room.meta.members[h.id] || {}
|
|
97
|
+
if ((m.kind || 'agent') === 'agent' && !h.chat && ok({ ...m, role: m.role || 'editor' })) out.push(h.name)
|
|
98
|
+
}
|
|
99
|
+
for (const ws of (room.conns ? room.conns.keys() : [])) {
|
|
100
|
+
const a = room.access && room.access.get(ws)
|
|
101
|
+
if (a && a.kind === 'agent' && ok(a) && room.names && room.names.get(ws)) out.push(room.names.get(ws))
|
|
102
|
+
}
|
|
103
|
+
return [...new Set(out)]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Who merges and who reviews a clash: { merger, reviewer, mode } with mode 'same' (both edits by
|
|
108
|
+
* one member: no reviewer), 'two' (the reviewer is a member), 'outside' (the GitHub side is not a
|
|
109
|
+
* member), or 'none' (nobody to merge: people are told).
|
|
110
|
+
*/
|
|
111
|
+
export function chooseRoles (room, key, h, { except = [] } = {}) {
|
|
112
|
+
const s = h.parties.session
|
|
113
|
+
// The other side: GitHub's author (a member, or someone outside Quilt), or a member back from editing offline.
|
|
114
|
+
const g = h.parties.github
|
|
115
|
+
const off = h.parties.offline
|
|
116
|
+
const o = g ? { name: g.member || null, ts: g.ts, outside: !g.member } : off ? { name: off.name, ts: off.ts, outside: false } : null
|
|
117
|
+
const om = o && o.name
|
|
118
|
+
if (s && om && s.name === om) {
|
|
119
|
+
if (!except.includes(s.name) && isOnline(room, s.name)) return { merger: s.name, reviewer: null, mode: 'same' }
|
|
120
|
+
const any = agentsFor(room, key, h.path).find((n) => !except.includes(n))
|
|
121
|
+
return any ? { merger: any, reviewer: s.name, mode: 'same' } : { merger: null, reviewer: s.name, mode: 'none' }
|
|
122
|
+
}
|
|
123
|
+
const parties = [s ? { name: s.name, ts: s.ts } : null, om ? { name: om, ts: o.ts } : null].filter(Boolean)
|
|
124
|
+
const online = parties.filter((p) => !except.includes(p.name) && isOnline(room, p.name)).sort((a, b) => b.ts - a.ts)
|
|
125
|
+
let merger = online[0] ? online[0].name : null
|
|
126
|
+
if (!merger) merger = agentsFor(room, key, h.path).find((n) => !except.includes(n)) || null
|
|
127
|
+
const reviewer = parties.map((p) => p.name).find((n) => n !== merger) || null
|
|
128
|
+
if (!merger) return { merger: null, reviewer, mode: 'none' }
|
|
129
|
+
return { merger, reviewer, mode: o && o.outside ? 'outside' : 'two' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Stores `text` as a blob in the mirror (kept by the clash's keep refs): its sha, or null. */
|
|
133
|
+
async function storeBlob (dir, text) {
|
|
134
|
+
const r = await runGit(dir, ['hash-object', '-w', '--stdin'], { input: Buffer.from(text, 'utf8') })
|
|
135
|
+
return r.ok ? r.out.trim() : null
|
|
136
|
+
}
|
|
137
|
+
async function readBlob (dir, sha) {
|
|
138
|
+
const r = await runGit(dir, ['cat-file', 'blob', sha], { buffer: true })
|
|
139
|
+
return r.ok ? r.out.toString('utf8') : null
|
|
140
|
+
}
|
|
141
|
+
async function fileAt (dir, commit, rel) {
|
|
142
|
+
const r = await runGit(dir, ['cat-file', 'blob', `${commit}:${rel}`], { buffer: true })
|
|
143
|
+
return r.ok ? r.out.toString('utf8') : null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const post = (room, taskId, text) => { try { addComment(room.doc, room.taskComments || room.doc.getMap('taskComments'), readTasks(room.tasks || room.doc.getMap('tasks')), { taskId, by: RELAY_BY, text: clip(text, MAX_COMMENT) }, RELAY_ORIGIN) } catch {} }
|
|
147
|
+
const dm = (room, to, text, kind = 'nudge') => { if (to && room.postChat) room.postChat({ by: RELAY_BY, to, kind, text }) }
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A file just held for a clash (relay-sync.js): its parties, merger and reviewer, the clash task
|
|
151
|
+
* with the thread's material on it, and both parties told. `texts`: { base, ours, theirs } (keys as
|
|
152
|
+
* the branch document has them). Mutates `h` (a state.held entry) and returns it.
|
|
153
|
+
*/
|
|
154
|
+
export async function openClash (room, key, rec, h, { dir, texts, now = Date.now() }) {
|
|
155
|
+
const e = room.branchDoc(key)
|
|
156
|
+
const commits = await upstreamCommits(dir, h.base, h.theirs, h.path)
|
|
157
|
+
const top = commits[0] || null
|
|
158
|
+
h.parties = {
|
|
159
|
+
session: sessionParty(e, h.path),
|
|
160
|
+
github: top ? { name: top.name, email: top.email, ts: top.ts, sha: top.sha, subject: top.subject, member: memberForAuthor(room, top) } : null
|
|
161
|
+
}
|
|
162
|
+
h.binary = !isText(texts.ours) || !isText(texts.theirs)
|
|
163
|
+
Object.assign(h, chooseRoles(room, key, h), { assignedAt: now, candidate: null })
|
|
164
|
+
if (isText(texts.ours)) h.oursBlob = await storeBlob(dir, texts.ours)
|
|
165
|
+
const tasks = room.tasks || room.doc.getMap('tasks')
|
|
166
|
+
const sideS = h.parties.session ? h.parties.session.name : 'the session'
|
|
167
|
+
const sideG = h.parties.github ? (h.parties.github.member || `${h.parties.github.name} (on GitHub, not in this session)`) : 'GitHub'
|
|
168
|
+
const title = clip(`Merge ${h.path} on ${key}: ${sideS} and ${sideG} changed the same lines`, 200)
|
|
169
|
+
let task = null
|
|
170
|
+
try {
|
|
171
|
+
task = addTask(room.doc, tasks, { title, by: RELAY_BY, assignee: h.merger || '', forAi: false, tool: '', files: [h.path] }, RELAY_ORIGIN)
|
|
172
|
+
} catch (err) { room.log && room.log(`[${room.name}] ${key}: could not put the clash on ${h.path} on the board: ${err.message}`) }
|
|
173
|
+
h.task = task ? task.id : null
|
|
174
|
+
if (task) {
|
|
175
|
+
const lines = [
|
|
176
|
+
`${h.path} is held on \`${key}\`: the session and GitHub changed the same lines. Nothing half-merged reaches anyone; everything else keeps syncing.`,
|
|
177
|
+
`- Session side: ${sideS}${h.parties.session ? ` (${new Date(h.parties.session.ts).toISOString().slice(0, 16)}Z)` : ''}`,
|
|
178
|
+
`- GitHub side: ${sideG}${commits.length ? `, in ${commits.length} commit${commits.length === 1 ? '' : 's'}: ${commits.slice(0, 5).map((c) => `${c.sha.slice(0, 7)} "${clip(c.subject, 80)}"`).join('; ')}` : ''}`,
|
|
179
|
+
`- Merger: ${h.merger || 'nobody yet (no party or agent online)'}; reviewer: ${h.reviewer || (h.mode === 'outside' ? 'none (the GitHub side is outside Quilt: they get a comment on their commit)' : 'none')}.`,
|
|
180
|
+
'- Checks: the merger runs the project\'s own checks (its test command) in its own folder or tool, on exactly the content it proposes, and says how they went; a changed proposal needs them again. The relay never runs code or an AI.',
|
|
181
|
+
'',
|
|
182
|
+
h.binary
|
|
183
|
+
? 'A binary file can\'t be merged: pick one side with quilt_clash keep_session or keep_github.'
|
|
184
|
+
: 'Agree on what each change was for first (quilt_message each other). Then the merger proposes the merged file with quilt_clash (action "propose", the full content without conflict markers, and the checks you ran on exactly that content); the reviewer accepts with quilt_clash accept. Only then is it written and pushed. Writing the file directly is refused while it is held.'
|
|
185
|
+
]
|
|
186
|
+
post(room, task.id, lines.join('\n'))
|
|
187
|
+
if (isText(texts.base) || texts.base === null) {
|
|
188
|
+
const b = isText(texts.base) ? texts.base : ''
|
|
189
|
+
if (isText(texts.ours)) post(room, task.id, `The session's change (base → session):\n\`\`\`diff\n${lineDiff(b, texts.ours, { max: MAX_COMMENT - 200 })}\n\`\`\``)
|
|
190
|
+
if (isText(texts.theirs)) post(room, task.id, `GitHub's change (base → ${h.theirs.slice(0, 7)}):\n\`\`\`diff\n${lineDiff(b, texts.theirs, { max: MAX_COMMENT - 200 })}\n\`\`\``)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
tellRoles(room, key, h, { fresh: true })
|
|
194
|
+
room.log && room.log(`[${room.name}] ${key}: clash on ${h.path}: merger ${h.merger || 'none'}, reviewer ${h.reviewer || 'none'} (${h.mode})`)
|
|
195
|
+
return h
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Tells the merger and the reviewer what is theirs to do (or the people, when nobody can merge). */
|
|
199
|
+
function tellRoles (room, key, h, { fresh = false, why = '' } = {}) {
|
|
200
|
+
const ref = h.task ? ` (task ${h.task})` : ''
|
|
201
|
+
if (h.merger) {
|
|
202
|
+
const person = (everyone(room).find((m) => m.name === h.merger) || {}).kind !== 'agent'
|
|
203
|
+
const how = person
|
|
204
|
+
? `merge it in your editor and save it without conflict markers (that settles it), or have your AI propose it with quilt_clash`
|
|
205
|
+
: 'propose the merged file with quilt_clash propose (no markers), with the checks you ran on it'
|
|
206
|
+
dm(room, h.merger, `${why ? `${why} ` : ''}You merge ${h.path} on ${key}${ref}: ${h.reviewer ? `${h.reviewer} reviews` : 'nobody needs to review'}.${h.binary ? ' It is a binary file: keep one side with quilt_clash keep_session or keep_github.' : ` ${h.reviewer && h.mode !== 'same' ? `Agree with ${h.reviewer} on intent first, then` : 'Then'} ${how}.`}`)
|
|
207
|
+
}
|
|
208
|
+
if (h.reviewer && fresh) dm(room, h.reviewer, `${h.path} on ${key} clashes with your work${ref}. ${h.merger ? `${h.merger} merges it; you review the result (quilt_clash accept), and nothing is pushed until you do${h.mode === 'two' ? ' or 15 minutes pass' : ''}.` : 'Nobody is online to merge it yet.'}`)
|
|
209
|
+
if (!h.merger && !h.toldNone) {
|
|
210
|
+
h.toldNone = Date.now()
|
|
211
|
+
if (room.postChat) room.postChat({ by: RELAY_BY, kind: 'nudge', text: `${key} has 1 clash in ${h.path}, waiting for an AI or you${ref}. Everything else keeps syncing.` })
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The held clash entry for `rel` on `key`, or null. */
|
|
216
|
+
export const clashOf = (room, key, rel) => syncStateOf(room, key).held.find((x) => x.path === rel && (x.why === 'clash' || x.why === 'checks')) || null
|
|
217
|
+
|
|
218
|
+
/** Writes `text` (or deletes, with null) into branch `key` as `by`, recorded in its history, and marks it pending for the next commit. */
|
|
219
|
+
function writeResolved (room, key, rel, text, by) {
|
|
220
|
+
const e = room.branchDoc(key)
|
|
221
|
+
const history = e.historyLog || (e.historyLog = new HistoryLog(e.doc, e.doc.getArray('history'), { origin: RELAY_ORIGIN }))
|
|
222
|
+
const before = e.files.get(rel)?.toString()
|
|
223
|
+
e.doc.transact(() => {
|
|
224
|
+
if (text === null) { e.files.delete(rel); e.blobs.delete(rel); history.record({ by, path: rel, kind: 'deleted', before, after: '' }); return }
|
|
225
|
+
e.blobs.delete(rel)
|
|
226
|
+
let y = e.files.get(rel)
|
|
227
|
+
if (!y) { y = new Y.Text(); e.files.set(rel, y) }
|
|
228
|
+
applyTextDiff(y, text)
|
|
229
|
+
history.record({ by, path: rel, kind: before === undefined ? 'created' : 'edited', before: before ?? '', after: text })
|
|
230
|
+
}, RELAY_ORIGIN)
|
|
231
|
+
if (room.outbound) room.outbound.note(key, rel)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Ends the clash on `rel`: released from held, kept as resolved (for recovery and undo), its task Done. */
|
|
235
|
+
export function finishClash (room, key, h, opts) { return finish(room, key, h, opts) }
|
|
236
|
+
function finish (room, key, h, { how, by, text = null, now = Date.now(), mergeHow = 'agent' }) {
|
|
237
|
+
const state = syncStateOf(room, key)
|
|
238
|
+
state.held = state.held.filter((x) => x !== h)
|
|
239
|
+
if (h.kind === 'offline' && h.mergeId) {
|
|
240
|
+
// The returning member's merge record (merges.js) closes with it.
|
|
241
|
+
try { const e = room.branchDoc(key); updateMerge(e.doc, e.doc.getMap('merges'), h.mergeId, { state: 'done', how: mergeHow, resolvedBy: String(by).slice(0, 80) }, RELAY_ORIGIN) } catch {}
|
|
242
|
+
// GitHub changed the file while it was held: that comes in now, against the base it was measured from (relay-sync.js).
|
|
243
|
+
if (h.upstreamMoved) state.held.push({ path: h.path, why: 'clash', base: h.upstreamMoved.base, theirs: h.upstreamMoved.theirs, head: h.upstreamMoved.theirs, was: null, regions: [], since: now })
|
|
244
|
+
}
|
|
245
|
+
if (!Array.isArray(state.resolved)) state.resolved = []
|
|
246
|
+
state.resolved.push({ path: h.path, kind: h.kind || 'upstream', base: h.base, theirs: h.theirs, oursBlob: h.oursBlob || null, otherBlob: h.otherBlob || null, baseBlob: h.baseBlob || null, candidateBlob: h.candidate ? h.candidate.blob : null, how, by, merger: h.merger, reviewer: h.reviewer, parties: h.parties, at: now, task: h.task })
|
|
247
|
+
state.resolved = state.resolved.filter((r) => now - r.at < RESOLVED_KEEP_MS).slice(-100)
|
|
248
|
+
if (h.task) {
|
|
249
|
+
try { updateTask(room.doc, room.tasks || room.doc.getMap('tasks'), { id: h.task, column: 'done', verified: `Clash on ${h.path} settled (${how}) by ${by}.` }, RELAY_ORIGIN) } catch {}
|
|
250
|
+
post(room, h.task, `Settled: ${how} by ${by}. ${text === null ? '' : 'The merged file is in the session and goes to GitHub with the next commit. '}Both sides stay recoverable: the session's version in the history${h.kind === 'offline' ? `, ${h.parties.offline.name}'s in the merge record` : h.theirs ? `, GitHub's in ${h.theirs.slice(0, 7)}` : ''}.`)
|
|
251
|
+
}
|
|
252
|
+
room.saveMeta()
|
|
253
|
+
room.broadcastBranches && room.broadcastBranches()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Sends the merged result to the GitHub side when they are outside Quilt: a comment on their commit (REST metadata). */
|
|
257
|
+
async function commentUpstream (room, key, h) {
|
|
258
|
+
const g = h.parties && h.parties.github
|
|
259
|
+
if (!g || g.member || !g.sha) return false
|
|
260
|
+
const rec = upstreamsOf(room)[key]
|
|
261
|
+
try {
|
|
262
|
+
const [owner, name] = String(rec.repo).replace(/^github\.com\//, '').split('/')
|
|
263
|
+
const token = await room.gitToken({ owner, name, slug: rec.repo }, { access: 'write' })
|
|
264
|
+
if (!token) return false
|
|
265
|
+
const res = await (room.githubFetch || globalThis.fetch)(`https://api.github.com/repos/${owner}/${name}/commits/${g.sha}/comments`, {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: { 'user-agent': 'quilt-relay', 'x-github-api-version': '2022-11-28', accept: 'application/vnd.github+json', authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
268
|
+
body: JSON.stringify({ body: `This commit changed the same lines of \`${h.path}\` as work in a Quilt session on \`${key}\`. ${h.merger} merged the two in the session; the merged file reaches \`${key}\` with the session's next commit.` })
|
|
269
|
+
})
|
|
270
|
+
return res.ok
|
|
271
|
+
} catch { return false }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The project's checks as the merger reported them for content `fp`: whether they let the merge through. */
|
|
275
|
+
const checksOk = (c, fp) => !!c && c.fp === fp && (c.result === 'passed' || c.result === 'none')
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* quilt_clash, for `who` ({ name, kind, access }) on branch `key`. Resolves { ok, text } or throws
|
|
279
|
+
* an Error with what to do. Actions:
|
|
280
|
+
* - propose { path, content, checks: { result: passed|failed|none, command } }: the merger's merged file.
|
|
281
|
+
* - accept { path, force }: the reviewer takes the proposal (force: a person, past failing checks).
|
|
282
|
+
* - keep_session / keep_github { path }: whoever holds it, or a person: one side, the other recoverable.
|
|
283
|
+
* - revert { path }: the reviewer (or a person) after it went through: their side back.
|
|
284
|
+
* - status: the clashes on this branch.
|
|
285
|
+
*/
|
|
286
|
+
export async function clashAction (room, { who, key, action, path: rel = '', content = null, checks = null, force = false }, { now = Date.now() } = {}) {
|
|
287
|
+
if (!CLASH_ACTIONS.includes(action)) throw new Error(`action is one of ${CLASH_ACTIONS.join(', ')}`)
|
|
288
|
+
const state = syncStateOf(room, key)
|
|
289
|
+
const person = who.kind !== 'agent'
|
|
290
|
+
if (action === 'status') {
|
|
291
|
+
const open = state.held.filter((h) => h.why === 'clash')
|
|
292
|
+
if (!open.length) return { ok: true, text: `No clashes on ${key}.` }
|
|
293
|
+
return { ok: true, text: open.map((h) => `- ${h.path}: merger ${h.merger || 'nobody yet'}, reviewer ${h.reviewer || 'none'}${h.candidate ? `; proposed by ${h.candidate.by} (checks ${h.candidate.checks ? h.candidate.checks.result : 'not reported'})` : '; no proposal yet'}${h.task ? ` (task ${h.task})` : ''}`).join('\n') }
|
|
294
|
+
}
|
|
295
|
+
if (action === 'revert') {
|
|
296
|
+
const r = [...(state.resolved || [])].reverse().find((x) => x.path === rel)
|
|
297
|
+
if (!r) throw new Error(`No settled clash on ${rel} to revert.`)
|
|
298
|
+
if (!person && who.name !== r.reviewer) throw new Error(`Only ${r.reviewer || 'a person'} (the reviewer) or a person can revert it.`)
|
|
299
|
+
const dir = await mirrorDirOf(room, key)
|
|
300
|
+
const reviewerSide = r.parties && r.parties.github && r.parties.github.member === r.reviewer ? 'github' : r.parties && r.parties.offline && r.parties.offline.name === r.reviewer ? 'offline' : 'session'
|
|
301
|
+
const text = reviewerSide === 'github' ? await fileAt(dir, r.theirs, rel) : reviewerSide === 'offline' ? (r.otherBlob ? await readBlob(dir, r.otherBlob) : null) : (r.oursBlob ? await readBlob(dir, r.oursBlob) : null)
|
|
302
|
+
if (text === null) throw new Error(`That version of ${rel} isn't kept any more.`)
|
|
303
|
+
writeResolved(room, key, rel, text, who.name)
|
|
304
|
+
r.reverted = { by: who.name, at: now }
|
|
305
|
+
room.saveMeta()
|
|
306
|
+
if (r.merger && r.merger !== who.name) dm(room, r.merger, `${who.name} reverted your merge of ${rel} on ${key} to ${reviewerSide === 'github' ? 'GitHub\'s' : 'the session\'s'} version.`)
|
|
307
|
+
return { ok: true, text: `Reverted ${rel} on ${key} to ${reviewerSide === 'github' ? 'GitHub\'s' : reviewerSide === 'offline' ? 'your offline' : 'the session\'s'} version; it goes to GitHub with the next commit.` }
|
|
308
|
+
}
|
|
309
|
+
const h = clashOf(room, key, rel)
|
|
310
|
+
if (!h) throw new Error(`${rel} isn't held for a clash on ${key}.`)
|
|
311
|
+
const dir = await mirrorDirOf(room, key)
|
|
312
|
+
if (action === 'keep_session' || action === 'keep_github' || action === 'keep_offline') {
|
|
313
|
+
if (h.kind === 'offline' && action === 'keep_github') throw new Error(`${rel} clashes with ${h.parties.offline.name}'s offline edits, not GitHub: keep_session or keep_offline.`)
|
|
314
|
+
if (h.kind !== 'offline' && action === 'keep_offline') throw new Error(`${rel} clashes with GitHub: keep_session or keep_github.`)
|
|
315
|
+
if (!person && who.name !== h.merger) throw new Error(`${h.merger} holds ${rel}: only they or a person can pick a side.`)
|
|
316
|
+
if (action === 'keep_offline') {
|
|
317
|
+
const text = h.otherDeleted ? null : h.otherBlob ? await readBlob(dir, h.otherBlob) : null
|
|
318
|
+
if (text === null && !h.otherDeleted) throw new Error(`${h.parties.offline.name}'s version isn't kept any more.`)
|
|
319
|
+
writeResolved(room, key, rel, text, who.name)
|
|
320
|
+
finish(room, key, h, { how: `kept ${h.parties.offline.name}'s version`, by: who.name, text, now, mergeHow: 'mine' })
|
|
321
|
+
if (h.reviewer && h.reviewer !== who.name) dm(room, h.reviewer, `${who.name} settled the clash on ${rel} on ${key} by keeping ${h.parties.offline.name}'s version; the session's is recoverable (quilt_clash revert).`)
|
|
322
|
+
return { ok: true, text: `Kept ${h.parties.offline.name}'s version of ${rel}; the session's is recoverable.` }
|
|
323
|
+
}
|
|
324
|
+
if (h.why === 'checks') throw new Error(`${rel} is held because checks failed after its merge, not for a clash: propose a fix, or a person accepts the failure (quilt_clash accept).`)
|
|
325
|
+
const text = action === 'keep_github' ? await fileAt(dir, h.theirs, rel) : docKey(room.branchDoc(key), rel)
|
|
326
|
+
if (action === 'keep_github') writeResolved(room, key, rel, text, who.name)
|
|
327
|
+
else if (room.outbound) room.outbound.note(key, rel)
|
|
328
|
+
finish(room, key, h, { how: action === 'keep_github' ? 'kept GitHub\'s version' : 'kept the session\'s version', by: who.name, text, now, mergeHow: action === 'keep_github' ? 'agent' : 'theirs' })
|
|
329
|
+
if (h.reviewer && h.reviewer !== who.name) dm(room, h.reviewer, `${who.name} settled the clash on ${rel} on ${key} by keeping ${action === 'keep_github' ? 'GitHub\'s' : 'the session\'s'} version; the other is recoverable (quilt_clash revert).`)
|
|
330
|
+
return { ok: true, text: `Kept ${action === 'keep_github' ? 'GitHub\'s' : 'the session\'s'} version of ${rel}; the other side is recoverable.` }
|
|
331
|
+
}
|
|
332
|
+
if (action === 'propose') {
|
|
333
|
+
if (who.name !== h.merger && !person) throw new Error(`${h.merger || 'Another member'} merges ${rel}${h.task ? ` (task ${h.task})` : ''}: you can't propose for it.`)
|
|
334
|
+
if (h.binary) throw new Error(`${rel} is binary: keep one side (keep_session or keep_github).`)
|
|
335
|
+
if (typeof content !== 'string') throw new Error('Give the merged file\'s full content.')
|
|
336
|
+
if (hasMarkers(content)) throw new Error('The proposal still has conflict markers: resolve them first.')
|
|
337
|
+
const c = checks && typeof checks === 'object' ? checks : null
|
|
338
|
+
if (!c || !['passed', 'failed', 'none'].includes(c.result)) throw new Error('Say how the project\'s checks went on exactly this content: checks.result passed, failed or none (the project has none), and checks.command.')
|
|
339
|
+
const fp = sha1(content)
|
|
340
|
+
const blob = await storeBlob(dir, content)
|
|
341
|
+
h.candidate = { by: who.name, at: now, fp, blob, checks: { result: c.result, command: String(c.command || '').slice(0, 300), fp } }
|
|
342
|
+
if (who.name !== h.merger) h.merger = who.name
|
|
343
|
+
room.saveMeta()
|
|
344
|
+
// Not waited for: the same member, a GitHub side outside Quilt, or a reviewer who is offline.
|
|
345
|
+
const wait = h.reviewer && h.reviewer !== who.name && h.mode !== 'same' && isOnline(room, h.reviewer)
|
|
346
|
+
if (!wait) {
|
|
347
|
+
if (!checksOk(h.candidate.checks, fp)) return { ok: false, text: `Proposed, but the checks failed (${h.candidate.checks.command || 'the project\'s checks'}): fix the merge and propose again, or a person accepts it past them.` }
|
|
348
|
+
await accept(room, key, h, { by: who.name, now, dir })
|
|
349
|
+
if (h.reviewer && h.reviewer !== who.name) dm(room, h.reviewer, `While you were away, ${who.name} merged ${rel} on ${key} with your work (task ${h.task}); it is pushed. If it isn't right, quilt_clash revert puts your version back.`)
|
|
350
|
+
return { ok: true, text: `Merged ${rel}: written to the session; it goes to GitHub with the next commit${h.parties.github && !h.parties.github.member ? '; the GitHub side gets a comment on their commit' : ''}.` }
|
|
351
|
+
}
|
|
352
|
+
dm(room, h.reviewer, `${who.name} proposed the merge of ${rel} on ${key} (task ${h.task}; checks: ${h.candidate.checks.result}${h.candidate.checks.command ? `, ${h.candidate.checks.command}` : ''}). Review it with quilt_clash status and accept with quilt_clash accept${room.meta.clashTimeout === 'publish' ? '; without an answer in 15 minutes it goes through (the owner\'s setting)' : ''}.`)
|
|
353
|
+
if (h.task) post(room, h.task, `${who.name} proposed:\n\`\`\`diff\n${lineDiff(docKey(room.branchDoc(key), rel) || '', content, { max: MAX_COMMENT - 200 })}\n\`\`\``)
|
|
354
|
+
return { ok: true, text: `Proposed. ${h.reviewer} reviews it; nothing is written or pushed until then.` }
|
|
355
|
+
}
|
|
356
|
+
// accept
|
|
357
|
+
if (h.candidate && isRelay(h.candidate.by) && checks && ['passed', 'none'].includes(checks.result)) {
|
|
358
|
+
// A merge the relay suggested (word by word): whoever accepts it ran the checks on it.
|
|
359
|
+
if (!person && who.name !== h.merger && who.name !== h.reviewer) throw new Error(`${h.merger} or ${h.reviewer} accepts the suggested merge of ${rel}.`)
|
|
360
|
+
h.candidate.checks = { result: checks.result, command: String(checks.command || '').slice(0, 300), fp: h.candidate.fp, by: who.name }
|
|
361
|
+
}
|
|
362
|
+
if (h.why === 'checks' && !h.candidate) {
|
|
363
|
+
if (!person) throw new Error(`Checks failed after the merge of ${rel}: propose a fix (quilt_clash propose, with the checks passing), or a person accepts the failure.`)
|
|
364
|
+
finish(room, key, h, { how: `the failing checks accepted by ${who.name}`, by: who.name, text: null, now })
|
|
365
|
+
if (room.outbound) room.outbound.note(key, rel)
|
|
366
|
+
return { ok: true, text: `Accepted the failing checks on ${rel}; it syncs to GitHub again.` }
|
|
367
|
+
}
|
|
368
|
+
if (!h.candidate) throw new Error(`Nothing is proposed for ${rel} yet: ${h.merger || 'the merger'} proposes first.`)
|
|
369
|
+
if (who.name === h.candidate.by && !person) throw new Error('The reviewer accepts, not the merger.')
|
|
370
|
+
if (isRelay(h.candidate.by) && !checksOk(h.candidate.checks, h.candidate.fp) && !(person && force)) throw new Error(`The relay suggested a word-by-word merge of ${rel}: accept it with checks (how the project's checks went on it), or propose another.`)
|
|
371
|
+
if (!person && h.reviewer && who.name !== h.reviewer && !isRelay(h.candidate.by)) throw new Error(`${h.reviewer} reviews ${rel}.`)
|
|
372
|
+
if (!checksOk(h.candidate.checks, h.candidate.fp)) {
|
|
373
|
+
const overridable = person && force && room.meta.clashOverride !== false
|
|
374
|
+
if (!overridable) throw new Error(`The checks on the proposal ${h.candidate.checks.result === 'failed' ? 'failed' : 'aren\'t for this content'}: the merger fixes it and proposes again${person ? ', or accept with force: true to take it as it is' : ''}.`)
|
|
375
|
+
}
|
|
376
|
+
await accept(room, key, h, { by: who.name, now, dir })
|
|
377
|
+
return { ok: true, text: `Accepted ${h.candidate.by}'s merge of ${rel}: written to the session; it goes to GitHub with the next commit.` }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function accept (room, key, h, { by, now, dir }) {
|
|
381
|
+
const text = await readBlob(dir, h.candidate.blob)
|
|
382
|
+
if (text === null) throw new Error('The proposal isn\'t kept any more: propose again.')
|
|
383
|
+
writeResolved(room, key, h.path, text, h.candidate.by)
|
|
384
|
+
noteMerged(room, key, h.path, h.parties || {}, { now })
|
|
385
|
+
finish(room, key, h, { how: `merged by ${h.candidate.by}, accepted by ${by}`, by, text, now })
|
|
386
|
+
// GitHub's side, outside Quilt, hears where its change went. Nothing is pushed here: the merged file goes with the session's next commit.
|
|
387
|
+
if (h.parties.github && !h.parties.github.member) commentUpstream(room, key, h).catch(() => {})
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function mirrorDirOf (room, key) {
|
|
391
|
+
const rec = upstreamsOf(room)[key]
|
|
392
|
+
const { parseRepo } = await import('./relay-upstream.js')
|
|
393
|
+
const repo = parseRepo(`https://${rec.repo}`)
|
|
394
|
+
return (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The relay's look at the open clashes on `key` (each look, and as people come and go): a proposal
|
|
399
|
+
* nobody reviewed in 15 minutes goes through (owner's policy 'publish', the default); a merger with
|
|
400
|
+
* no proposal after 15 minutes hands it on (the reviewer, then any agent); a clash nobody could take
|
|
401
|
+
* is given to whoever is online now. Returns what changed.
|
|
402
|
+
*/
|
|
403
|
+
export async function reviewClashes (room, key, { now = Date.now(), reviewMs = CLASH_REVIEW_MS, idleMs = CLASH_MERGER_IDLE_MS } = {}) {
|
|
404
|
+
const state = syncStateOf(room, key)
|
|
405
|
+
const out = []
|
|
406
|
+
for (const h of [...state.held]) {
|
|
407
|
+
if (h.why !== 'clash' || !h.parties) continue
|
|
408
|
+
if (h.candidate && h.reviewer && now - h.candidate.at >= reviewMs && room.meta.clashTimeout === 'publish' && checksOk(h.candidate.checks, h.candidate.fp)) {
|
|
409
|
+
const dir = await mirrorDirOf(room, key)
|
|
410
|
+
await accept(room, key, h, { by: RELAY_BY, now, dir })
|
|
411
|
+
dm(room, h.reviewer, `${h.reviewer}, the merge of ${h.path} on ${key} by ${h.candidate.by} went through after 15 minutes without your review (task ${h.task}). If it isn't right, quilt_clash revert puts your version back.`)
|
|
412
|
+
out.push({ path: h.path, timedOut: true })
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
if (!h.candidate && h.merger && now - (h.assignedAt || 0) >= idleMs) {
|
|
416
|
+
const tried = [...(h.tried || []), h.merger]
|
|
417
|
+
const next = h.reviewer && !tried.includes(h.reviewer) && isOnline(room, h.reviewer) ? h.reviewer : agentsFor(room, key, h.path).find((n) => !tried.includes(n)) || null
|
|
418
|
+
if (next) {
|
|
419
|
+
const was = h.merger
|
|
420
|
+
h.tried = tried; h.merger = next; h.assignedAt = now
|
|
421
|
+
if (h.reviewer === next) h.reviewer = was
|
|
422
|
+
if (h.task) { try { updateTask(room.doc, room.tasks || room.doc.getMap('tasks'), { id: h.task, assignee: next, forAi: false, tool: '' }, RELAY_ORIGIN) } catch {} ; post(room, h.task, `Handed to ${next}: ${was} made no proposal in 15 minutes.`) }
|
|
423
|
+
tellRoles(room, key, h, { why: `${was} made no proposal for 15 minutes.` })
|
|
424
|
+
out.push({ path: h.path, handedTo: next })
|
|
425
|
+
}
|
|
426
|
+
continue
|
|
427
|
+
}
|
|
428
|
+
if (!h.merger) {
|
|
429
|
+
const r = chooseRoles(room, key, h)
|
|
430
|
+
if (r.merger) {
|
|
431
|
+
Object.assign(h, { merger: r.merger, reviewer: r.reviewer || h.reviewer, mode: r.mode, assignedAt: now })
|
|
432
|
+
if (h.task) { try { updateTask(room.doc, room.tasks || room.doc.getMap('tasks'), { id: h.task, assignee: r.merger, forAi: false, tool: '' }, RELAY_ORIGIN) } catch {} }
|
|
433
|
+
tellRoles(room, key, h, { why: `${r.merger} is online now.` })
|
|
434
|
+
out.push({ path: h.path, handedTo: r.merger })
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (out.length) room.saveMeta()
|
|
439
|
+
return out
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Whether `rel` on branch `key` is held for a clash (agents may not write it; relay-mcp.js, server.js checkChange). */
|
|
443
|
+
export const heldForClash = (room, key, rel) => !!(room.meta.sync && room.meta.sync[key] && Array.isArray(room.meta.sync[key].held) && room.meta.sync[key].held.some((h) => h.path === rel && (h.why === 'clash' || h.why === 'checks')))
|
|
444
|
+
|
|
445
|
+
export const CLASH_DESCRIPTION = 'Resolve a file held because the session and GitHub changed the same lines (see the clash task). ' +
|
|
446
|
+
'The merger proposes the merged file (action "propose": the full content without conflict markers, and the result of the project\'s checks you ran on exactly that content); the reviewer accepts it ("accept"); only then is it written to the session, to go to GitHub with the next commit. ' +
|
|
447
|
+
'Whoever holds the file (or a person) can keep one side ("keep_session" or "keep_github"); the other stays recoverable. The reviewer can undo a merge that went through ("revert"). "status" lists the clashes on your branch. Writing a held file directly is refused.'
|
|
448
|
+
export const clashSchema = (z) => ({
|
|
449
|
+
action: z.enum(CLASH_ACTIONS).describe('propose, accept, keep_session, keep_github, revert or status'),
|
|
450
|
+
path: z.string().max(1024).optional().describe('The held file'),
|
|
451
|
+
content: z.string().max(1024 * 1024).optional().describe('propose: the merged file, in full, without conflict markers'),
|
|
452
|
+
checks: z.object({ result: z.enum(['passed', 'failed', 'none']), command: z.string().max(300).optional() }).optional().describe('propose (or accept, for a merge the relay suggested): how the project\'s checks went on exactly this content (none: the project has no checks)'),
|
|
453
|
+
force: z.boolean().optional().describe('accept, people only: take the proposal past failing checks')
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* A merged file (an automatic merge on the way in, or a clash settled) is on record until it is
|
|
458
|
+
* pushed: then the commit it went in is, with both parties, so failing checks on that commit can
|
|
459
|
+
* pull them back in (plan §7.3, conflicts with no textual overlap).
|
|
460
|
+
*/
|
|
461
|
+
export function noteMerged (room, key, rel, parties, { now = Date.now() } = {}) {
|
|
462
|
+
const state = syncStateOf(room, key)
|
|
463
|
+
if (!state.merged || typeof state.merged !== 'object') state.merged = {}
|
|
464
|
+
state.merged[rel] = { parties, at: now }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Called by the outbound push: the merged files that went in `sha`. */
|
|
468
|
+
export function notePushedMerges (room, key, sha, changed) {
|
|
469
|
+
const state = syncStateOf(room, key)
|
|
470
|
+
if (!state.merged) return
|
|
471
|
+
const files = changed.filter((rel) => state.merged[rel])
|
|
472
|
+
if (!files.length) return
|
|
473
|
+
if (!state.mergeCommits || typeof state.mergeCommits !== 'object') state.mergeCommits = {}
|
|
474
|
+
state.mergeCommits[sha] = { files, parties: files.map((rel) => state.merged[rel].parties), at: Date.now() }
|
|
475
|
+
for (const rel of files) delete state.merged[rel]
|
|
476
|
+
const keys = Object.keys(state.mergeCommits)
|
|
477
|
+
for (const k of keys.slice(0, Math.max(0, keys.length - 20))) delete state.mergeCommits[k]
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** The failing check runs' annotations on `sha` (REST metadata): [{ path, line }], or [] when GitHub doesn't say. */
|
|
481
|
+
async function failingLines (room, key, sha) {
|
|
482
|
+
const rec = upstreamsOf(room)[key]
|
|
483
|
+
try {
|
|
484
|
+
const [owner, name] = String(rec.repo).replace(/^github\.com\//, '').split('/')
|
|
485
|
+
const token = await room.gitToken({ owner, name, slug: rec.repo })
|
|
486
|
+
const f = room.githubFetch || globalThis.fetch
|
|
487
|
+
const headers = { 'user-agent': 'quilt-relay', 'x-github-api-version': '2022-11-28', accept: 'application/vnd.github+json', ...(token ? { authorization: `Bearer ${token}` } : {}) }
|
|
488
|
+
const runs = await (await f(`https://api.github.com/repos/${owner}/${name}/commits/${sha}/check-runs?per_page=50`, { headers })).json()
|
|
489
|
+
const out = []
|
|
490
|
+
for (const r of (runs && runs.check_runs) || []) {
|
|
491
|
+
if (r.conclusion !== 'failure' || !r.id) continue
|
|
492
|
+
const ann = await (await f(`https://api.github.com/repos/${owner}/${name}/check-runs/${r.id}/annotations?per_page=50`, { headers })).json()
|
|
493
|
+
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) })
|
|
494
|
+
}
|
|
495
|
+
return out
|
|
496
|
+
} catch { return [] }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* GitHub's checks on `sha` (a check_suite) finished. Failing on a commit that carried merged files:
|
|
501
|
+
* the files the failing lines are in (all its merged files when GitHub names none) are held, and
|
|
502
|
+
* both parties of their merges are pulled into one task: the merger proposes a fix with the checks
|
|
503
|
+
* passing, or a person accepts the failure. Passing on a commit at or after one that held files:
|
|
504
|
+
* they are let go of. Returns what changed.
|
|
505
|
+
*/
|
|
506
|
+
export async function noteMergeChecks (room, key, { sha, conclusion }, { now = Date.now() } = {}) {
|
|
507
|
+
const state = syncStateOf(room, key)
|
|
508
|
+
const out = []
|
|
509
|
+
if (conclusion === 'success') {
|
|
510
|
+
const holds = state.held.filter((h) => h.why === 'checks')
|
|
511
|
+
if (!holds.length) return out
|
|
512
|
+
const dir = await mirrorDirOf(room, key)
|
|
513
|
+
for (const h of holds) {
|
|
514
|
+
if (h.sha !== sha && !(await runGit(dir, ['merge-base', '--is-ancestor', h.sha, sha])).ok) continue
|
|
515
|
+
finish(room, key, h, { how: `checks passed on ${sha.slice(0, 7)}`, by: RELAY_BY, text: null, now })
|
|
516
|
+
if (room.outbound) room.outbound.note(key, h.path)
|
|
517
|
+
out.push({ path: h.path, released: true })
|
|
518
|
+
}
|
|
519
|
+
return out
|
|
520
|
+
}
|
|
521
|
+
if (conclusion !== 'failure' && conclusion !== 'timed_out') return out
|
|
522
|
+
const mc = state.mergeCommits && state.mergeCommits[sha]
|
|
523
|
+
if (!mc) return out
|
|
524
|
+
const lines = await failingLines(room, key, sha)
|
|
525
|
+
const named = mc.files.filter((rel) => lines.some((l) => l.path === rel))
|
|
526
|
+
const files = named.length ? named : mc.files
|
|
527
|
+
const parties = mc.parties[mc.files.indexOf(files[0])] || {}
|
|
528
|
+
const names = [parties.session && parties.session.name, parties.github && (parties.github.member || null)].filter(Boolean)
|
|
529
|
+
const merger = names.find((n) => isOnline(room, n)) || agentsFor(room, key, files[0])[0] || null
|
|
530
|
+
const reviewer = names.find((n) => n !== merger) || null
|
|
531
|
+
const tasks = room.tasks || room.doc.getMap('tasks')
|
|
532
|
+
let task = null
|
|
533
|
+
try { task = addTask(room.doc, tasks, { title: clip(`Checks fail after merging ${files.join(', ')} on ${key}`, 200), by: RELAY_BY, assignee: merger || '', forAi: false, tool: '', files }, RELAY_ORIGIN) } catch {}
|
|
534
|
+
for (const rel of files) {
|
|
535
|
+
if (state.held.some((h) => h.path === rel)) continue
|
|
536
|
+
state.held.push({ path: rel, why: 'checks', sha, base: sha, theirs: sha, parties, merger, reviewer, mode: reviewer ? 'two' : 'outside', task: task ? task.id : null, since: now, assignedAt: now, candidate: null })
|
|
537
|
+
out.push({ path: rel, held: true })
|
|
538
|
+
}
|
|
539
|
+
if (task) post(room, task.id, [`GitHub's checks failed on ${sha.slice(0, 7)}, which carried the merge of ${files.join(', ')}. ${files.length === 1 ? 'It is' : 'They are'} held (not pushed again) until a fix passes the checks, or a person accepts the failure.`, ...lines.filter((l) => files.includes(l.path)).slice(0, 10).map((l) => `- ${l.path}:${l.line} ${l.message}`), '', `Merger: ${merger || 'nobody online yet'}; reviewer: ${reviewer || 'none'}. Propose the fix with quilt_clash propose (with the checks you ran passing).`].join('\n'))
|
|
540
|
+
for (const n of names) dm(room, n, `GitHub's checks failed after the merge of ${files.join(', ')} on ${key} (task ${task ? task.id : '?'}): ${n === merger ? 'you fix it' : `${merger || 'an agent'} fixes it and you review`}.`)
|
|
541
|
+
room.saveMeta()
|
|
542
|
+
return out
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** For tests: who is told what when a clash has nobody to merge it. */
|
|
546
|
+
export const tellClashRoles = tellRoles
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* A member came back with offline edits that clash with the session's (plan §7.1; session.js
|
|
550
|
+
* openConflict): the session's version stays, theirs is kept in the mirror, and the clash goes the
|
|
551
|
+
* same way as one with GitHub (merger, reviewer, proposal, acceptance). `ours` is the returning
|
|
552
|
+
* member's text (null: they deleted it); `base` what they last had.
|
|
553
|
+
*/
|
|
554
|
+
export async function openOfflineClash (room, key, { who, path: rel, mergeId = null, base = null, ours = null, oursDeleted = false, binary = false }, { now = Date.now() } = {}) {
|
|
555
|
+
if (!isSafeRelPath(rel)) throw new Error('not a path in the project')
|
|
556
|
+
const state = syncStateOf(room, key)
|
|
557
|
+
const had = state.held.find((h) => h.path === rel)
|
|
558
|
+
if (had) return had
|
|
559
|
+
const dir = await mirrorDirOf(room, key)
|
|
560
|
+
const e = room.branchDoc(key)
|
|
561
|
+
const session = docKey(e, rel)
|
|
562
|
+
const h = {
|
|
563
|
+
path: rel, why: 'clash', kind: 'offline', since: now, mergeId, base: null, theirs: null,
|
|
564
|
+
binary: !!binary || (session !== null && !isText(session)),
|
|
565
|
+
baseBlob: typeof base === 'string' ? await storeBlob(dir, base) : null,
|
|
566
|
+
otherBlob: typeof ours === 'string' ? await storeBlob(dir, ours) : null,
|
|
567
|
+
otherDeleted: !!oursDeleted,
|
|
568
|
+
oursBlob: isText(session) ? await storeBlob(dir, session) : null,
|
|
569
|
+
was: fingerprint(session),
|
|
570
|
+
parties: { session: sessionParty(e, rel, { except: who.name }), github: null, offline: { name: who.name, ts: now } }
|
|
571
|
+
}
|
|
572
|
+
Object.assign(h, chooseRoles(room, key, h), { assignedAt: now, candidate: null })
|
|
573
|
+
state.held.push(h)
|
|
574
|
+
const tasks = room.tasks || room.doc.getMap('tasks')
|
|
575
|
+
const sideS = h.parties.session ? h.parties.session.name : 'the session'
|
|
576
|
+
let task = null
|
|
577
|
+
try { task = addTask(room.doc, tasks, { title: clip(`Merge ${rel} on ${key}: ${who.name} edited it offline, and ${sideS} in the session`, 200), by: RELAY_BY, assignee: h.merger || '', forAi: false, tool: '', files: [rel] }, RELAY_ORIGIN) } catch {}
|
|
578
|
+
h.task = task ? task.id : null
|
|
579
|
+
if (task) {
|
|
580
|
+
post(room, task.id, `${rel} is held on \`${key}\`: ${who.name} changed it while offline, and ${sideS} changed the same lines in the session. The session's version stays as it is; ${who.name}'s is kept. Merger: ${h.merger || 'nobody yet'}; reviewer: ${h.reviewer || 'none'}. Propose the merged file with quilt_clash propose; the reviewer accepts. Or keep one side: keep_session, keep_offline.`)
|
|
581
|
+
const b = typeof base === 'string' ? base : ''
|
|
582
|
+
if (isText(session)) post(room, task.id, `The session's change (base → session):\n\`\`\`diff\n${lineDiff(b, session, { max: MAX_COMMENT - 200 })}\n\`\`\``)
|
|
583
|
+
if (typeof ours === 'string') post(room, task.id, `${who.name}'s offline change (base → theirs):\n\`\`\`diff\n${lineDiff(b, ours, { max: MAX_COMMENT - 200 })}\n\`\`\``)
|
|
584
|
+
}
|
|
585
|
+
tellRoles(room, key, h, { fresh: true })
|
|
586
|
+
room.saveMeta()
|
|
587
|
+
room.log && room.log(`[${room.name}] ${key}: ${who.name}'s offline edits to ${rel} clash with the session's: merger ${h.merger || 'none'}, reviewer ${h.reviewer || 'none'}`)
|
|
588
|
+
return h
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** A merge the relay worked out word by word (merge3.js mergeSmart): suggested, never landed by itself (addendum 16.8). */
|
|
592
|
+
export async function suggestCandidate (room, key, h, text, { dir, now = Date.now(), how = 'words' } = {}) {
|
|
593
|
+
const blob = await storeBlob(dir, text)
|
|
594
|
+
h.candidate = { by: RELAY_BY, at: now, fp: sha1(text), blob, checks: null, generated: how }
|
|
595
|
+
if (h.task) post(room, h.task, `The relay can merge ${h.path} ${how === 'words' ? 'word by word' : how}, but a merge like that can change what the code means, so it is only a suggestion: ${h.merger || 'the merger'} or ${h.reviewer || 'the reviewer'} accepts it with quilt_clash accept (with how the project's checks went on it), or proposes another.\n\`\`\`\n${clip(text, MAX_COMMENT - 400)}\n\`\`\``)
|
|
596
|
+
return h
|
|
597
|
+
}
|