heyquilt 0.3.90 → 0.3.107
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/README.md +14 -11
- package/RELEASES.md +135 -2
- package/bin/quilt.js +45 -12
- package/package.json +3 -3
- package/src/account.js +28 -0
- package/src/agents/claude-code.js +11 -4
- package/src/agents/cursor-transcripts.js +11 -4
- package/src/ai-vm-signin.js +14 -2
- package/src/ai-vm.js +150 -8
- package/src/api/agent-auth.js +9 -3
- package/src/api/fly-machines.js +72 -15
- package/src/api/memory-store.js +142 -6
- package/src/api/relay-cluster.js +337 -43
- package/src/api/relay-machines.js +35 -3
- package/src/api/releases.js +87 -0
- package/src/api/routes/agents.js +111 -16
- package/src/api/routes/ai-vm.js +146 -19
- package/src/api/routes/orgs.js +3 -1
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/workspaces.js +8 -1
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +40 -9
- package/src/api/supabase-store.js +121 -11
- package/src/autowork.js +3 -15
- package/src/catchup.js +1 -1
- package/src/clash.js +10 -22
- package/src/control.js +18 -15
- package/src/fsutil.js +11 -1
- package/src/gitstate.js +29 -5
- package/src/hooks.js +1 -1
- package/src/mcp-proxy.js +7 -2
- package/src/mcp.js +22 -25
- package/src/merge3.js +94 -12
- package/src/pass-source.js +9 -2
- package/src/pathrules.js +7 -0
- package/src/relay-clash.js +101 -484
- package/src/relay-githttp.js +36 -7
- package/src/relay-mcp.js +19 -7
- package/src/relay-metrics.js +66 -1
- package/src/relay-outbound.js +154 -25
- package/src/relay-sync.js +133 -136
- package/src/relay-turn-sync.js +250 -51
- package/src/relay-upstream.js +49 -179
- package/src/releases.js +16 -9
- package/src/runner.js +3 -2
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +71 -44
- package/src/session.js +347 -262
- package/src/status.js +3 -5
- package/src/sync-words.js +1 -1
- package/src/turn-sync-plan.js +13 -10
- package/src/turn-sync.js +32 -11
- package/src/ui/agent-guide.js +4 -4
- package/src/ui/app.css +208 -23
- package/src/ui/app.js +4 -0
- package/src/ui/cloud-ai-model.js +3 -2
- package/src/ui/common.js +2 -0
- package/src/ui/fileview.js +28 -1
- package/src/ui/home.js +32 -33
- package/src/ui/loom.css +18 -2
- package/src/ui/loom.js +41 -8
- package/src/ui/rail-model.js +6 -9
- package/src/ui/releases.js +1 -1
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +47 -149
- package/src/ui/tree.js +23 -18
- package/src/ui/workspaces.js +5 -0
- package/src/ui-server.js +140 -28
- package/src/update-check.js +1 -1
- package/src/upstream.js +75 -38
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/ui/merges.js +0 -158
package/src/relay-clash.js
CHANGED
|
@@ -1,47 +1,29 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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.
|
|
4
10
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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'
|
|
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.
|
|
26
13
|
import { syncStateOf, runGit } from './relay-git.js'
|
|
27
|
-
import { upstreamsOf,
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
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'
|
|
14
|
+
import { upstreamsOf, RELAY_ORIGIN } from './relay-upstream.js'
|
|
15
|
+
import { readTasks, updateTask } from './tasks.js'
|
|
16
|
+
import { RELAY_BY } from './branches.js'
|
|
34
17
|
import { slug } from './github-commit.js'
|
|
35
18
|
import { updateMerge } from './merges.js'
|
|
36
|
-
import { isSafeRelPath } from './pathrules.js'
|
|
37
19
|
|
|
38
|
-
export const CLASH_REVIEW_MS = 15 * 60 * 1000
|
|
39
|
-
export const CLASH_MERGER_IDLE_MS = 15 * 60 * 1000
|
|
40
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). */
|
|
41
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.'
|
|
42
26
|
|
|
43
|
-
const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex')
|
|
44
|
-
const isText = (k) => typeof k === 'string' && !k.startsWith('bin:')
|
|
45
27
|
const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
|
|
46
28
|
|
|
47
29
|
/** Everyone in the session: its members and its owner (who may not be in the members list): [{ id, name, kind, github? }]. */
|
|
@@ -51,15 +33,6 @@ export function everyone (room) {
|
|
|
51
33
|
return out
|
|
52
34
|
}
|
|
53
35
|
|
|
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
36
|
/** A git author as a member: by the agents' or people's noreply address, or the GitHub account a person linked. */
|
|
64
37
|
export function memberForAuthor (room, { name = '', email = '' }) {
|
|
65
38
|
const e = String(email || '').toLowerCase()
|
|
@@ -71,392 +44,104 @@ export function memberForAuthor (room, { name = '', email = '' }) {
|
|
|
71
44
|
return null
|
|
72
45
|
}
|
|
73
46
|
|
|
74
|
-
/** The commits between `base` and `head` that changed `rel`, newest first: [{ sha, name, email, ts, subject }]. */
|
|
47
|
+
/** The commits between `base` and `head` that changed `rel`, newest first: [{ sha, name, email, ts, subject }] (ts: commit time). */
|
|
75
48
|
export async function upstreamCommits (dir, base, head, rel) {
|
|
76
49
|
const r = await runGit(dir, ['log', '--format=%H%x1f%an%x1f%ae%x1f%ct%x1f%s', `${base}..${head}`, '--', rel])
|
|
77
50
|
if (!r.ok) return []
|
|
78
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 } })
|
|
79
52
|
}
|
|
80
53
|
|
|
81
|
-
/**
|
|
82
|
-
function
|
|
83
|
-
|
|
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') })
|
|
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') })
|
|
135
57
|
return r.ok ? r.out.trim() : null
|
|
136
58
|
}
|
|
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
59
|
|
|
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
60
|
const dm = (room, to, text, kind = 'nudge') => { if (to && room.postChat) room.postChat({ by: RELAY_BY, to, kind, text }) }
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 })
|
|
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)
|
|
230
66
|
}, 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
67
|
}
|
|
255
68
|
|
|
256
|
-
/**
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 }
|
|
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')
|
|
272
75
|
}
|
|
273
76
|
|
|
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
77
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* - revert { path }: the reviewer (or a person) after it went through: their side back.
|
|
284
|
-
* - status: the clashes on this branch.
|
|
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.
|
|
285
83
|
*/
|
|
286
|
-
export
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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' : ''}.`)
|
|
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 {}
|
|
375
110
|
}
|
|
376
|
-
|
|
377
|
-
|
|
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(() => {})
|
|
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 })
|
|
388
113
|
}
|
|
389
114
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
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)
|
|
395
122
|
}
|
|
396
123
|
|
|
397
124
|
/**
|
|
398
|
-
*
|
|
399
|
-
*
|
|
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.
|
|
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.
|
|
402
127
|
*/
|
|
403
|
-
export async function
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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 }
|
|
440
131
|
}
|
|
441
132
|
|
|
442
|
-
|
|
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.'
|
|
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.'
|
|
448
134
|
export const clashSchema = (z) => ({
|
|
449
|
-
action: z.enum(CLASH_ACTIONS).describe('
|
|
450
|
-
path: z.string().max(1024).optional().describe('
|
|
451
|
-
content: z.string().max(1024 * 1024).optional().describe('
|
|
452
|
-
checks: z.object({ result: z.enum(['passed', 'failed', 'none']), command: z.string().max(300).optional() }).optional().describe('
|
|
453
|
-
force: z.boolean().optional().describe('
|
|
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')
|
|
454
140
|
})
|
|
455
141
|
|
|
456
142
|
/**
|
|
457
|
-
* A merged file (
|
|
458
|
-
*
|
|
459
|
-
* pull them back in (plan §7.3, conflicts with no textual overlap).
|
|
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).
|
|
460
145
|
*/
|
|
461
146
|
export function noteMerged (room, key, rel, parties, { now = Date.now() } = {}) {
|
|
462
147
|
const state = syncStateOf(room, key)
|
|
@@ -498,100 +183,32 @@ async function failingLines (room, key, sha) {
|
|
|
498
183
|
|
|
499
184
|
/**
|
|
500
185
|
* GitHub's checks on `sha` (a check_suite) finished. Failing on a commit that carried merged files:
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
* they are let go of. Returns what changed.
|
|
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 }].
|
|
505
189
|
*/
|
|
506
190
|
export async function noteMergeChecks (room, key, { sha, conclusion }, { now = Date.now() } = {}) {
|
|
507
191
|
const state = syncStateOf(room, key)
|
|
508
192
|
const out = []
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
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 })
|
|
520
198
|
}
|
|
521
199
|
if (conclusion !== 'failure' && conclusion !== 'timed_out') return out
|
|
522
200
|
const mc = state.mergeCommits && state.mergeCommits[sha]
|
|
523
|
-
if (!mc) return out
|
|
201
|
+
if (!mc || mc.told) return out
|
|
202
|
+
mc.told = now
|
|
524
203
|
const lines = await failingLines(room, key, sha)
|
|
525
204
|
const named = mc.files.filter((rel) => lines.some((l) => l.path === rel))
|
|
526
205
|
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
206
|
for (const rel of files) {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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 })
|
|
538
212
|
}
|
|
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
213
|
return out
|
|
543
214
|
}
|
|
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
|
-
}
|