heyquilt 0.3.90 → 0.3.106
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -11
- package/RELEASES.md +131 -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 +141 -5
- 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 +143 -17
- 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 +120 -10
- 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 +136 -27
- 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-sync.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
// GitHub → session, through the relay's git mirror (relay-git.js). A push on GitHub (a webhook,
|
|
2
2
|
// or the relay's own look) is fetched into the mirror and merged into the session's branch
|
|
3
3
|
// document file by file: base, the commit the session's content is measured from; theirs, the
|
|
4
|
-
// new head; ours, the session's content now.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// new head; ours, the session's content now. Every file lands at once: where both sides changed
|
|
5
|
+
// the same lines Quilt combines them itself, the newer edit keeping those lines (upstream.js
|
|
6
|
+
// combine; relay-clash.js tells whoever's lines lost). Only a file a member holds (or one the
|
|
7
|
+
// relay can't read) is held aside with its own base and theirs (so a later head never
|
|
8
|
+
// reinterprets it against another ancestor), and merged the same way once they let go. The session
|
|
7
9
|
// is the master node: every folder and agent gets the merged files from here, and a folder
|
|
8
10
|
// follows the relay's commit with git instead of pulling (session.js). Never a history merge;
|
|
9
11
|
// nothing is written to GitHub here.
|
|
10
12
|
import * as Y from 'yjs'
|
|
11
|
-
import {
|
|
12
|
-
import { upstreamsOf,
|
|
13
|
+
import { noteMerged, upstreamCommits, memberForAuthor, tellAutoMerge, storeBlob, noteResolved, closeOldClash, lostLines } from './relay-clash.js'
|
|
14
|
+
import { upstreamsOf, closeLanded, ignoreOf, docKey, parseRepo, catcherAble, RELAY_ORIGIN } from './relay-upstream.js'
|
|
13
15
|
import { syncStateOf, runGit, GitFailed, authEnv } from './relay-git.js'
|
|
14
|
-
import { planCatchUp, publishedOf } from './upstream.js'
|
|
15
|
-
import {
|
|
16
|
+
import { planCatchUp, publishedOf, combine } from './upstream.js'
|
|
17
|
+
import { unionMatcher } from './merge3.js'
|
|
16
18
|
import { applyTextDiff } from './textdiff.js'
|
|
17
19
|
import { looksBinary, sha1 } from './fsutil.js'
|
|
18
20
|
import { LARGE_FILE_BYTES, MAX_TEXT_BYTES, isIgnored, isSafeRelPath } from './pathrules.js'
|
|
@@ -26,7 +28,6 @@ export const BACKSTOP_MS = 60 * 1000
|
|
|
26
28
|
export const ACTIVE_BACKSTOP_MS = 60 * 1000
|
|
27
29
|
|
|
28
30
|
const plural = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
|
|
29
|
-
const isText = (k) => typeof k === 'string' && !k.startsWith('bin:')
|
|
30
31
|
const SHA = /^[0-9a-f]{40,64}$/
|
|
31
32
|
|
|
32
33
|
/** The paths that differ between two commits: Map path -> A, M or D (a type change counts as M; a rename as D plus A). */
|
|
@@ -116,9 +117,10 @@ export async function keepRefs (dir, room, key, { base, others = [] }) {
|
|
|
116
117
|
/**
|
|
117
118
|
* One look at session branch `key` through the mirror (the caller checked relayLooks). Fetches the
|
|
118
119
|
* branch, and when the head moved on from the sync state's base, merges every changed file into
|
|
119
|
-
* the branch document
|
|
120
|
-
*
|
|
121
|
-
*
|
|
120
|
+
* the branch document, combining what both sides changed (the newer edit keeps the lines both
|
|
121
|
+
* changed: GitHub's commit time against the session's last edit of the file); files a member
|
|
122
|
+
* holds are held with their own base and theirs, and merged once they let go. Returns what
|
|
123
|
+
* happened: { state, ... } (for tests and the log). Never throws.
|
|
122
124
|
*/
|
|
123
125
|
export async function bringInBranch (room, key, { log = () => {}, now = () => Date.now(), inQueue = false } = {}) {
|
|
124
126
|
const recs = upstreamsOf(room)
|
|
@@ -198,20 +200,7 @@ export async function bringInBranch (room, key, { log = () => {}, now = () => Da
|
|
|
198
200
|
const disk = (rel) => docKey(e, rel)
|
|
199
201
|
let landed = 0
|
|
200
202
|
let released = []
|
|
201
|
-
//
|
|
202
|
-
try { await reviewClashes(room, key, { now: started }) } catch (err) { log(`[${room.name}] ${key}: could not look at the clashes: ${err.message}`) }
|
|
203
|
-
// Held files first, each against its own base and theirs: a claim let go of, a clash merged.
|
|
204
|
-
for (const h of state.held) {
|
|
205
|
-
if (h.why !== 'clash' || h.parties || h.kind === 'offline' || !h.base || !h.theirs) continue
|
|
206
|
-
try {
|
|
207
|
-
const [b, t] = await Promise.all([filesAt(dir, h.base, [h.path]), filesAt(dir, h.theirs, [h.path])])
|
|
208
|
-
const bb = keyOf(b.get(h.path), h.path, { bins, tooLarge }); const tt = keyOf(t.get(h.path), h.path, { bins, tooLarge })
|
|
209
|
-
const m = isText(bb) && isText(disk(h.path)) && isText(tt) ? mergeSmart(bb, disk(h.path), tt, { union: (await unionFor(e, dir, null))(h.path) }) : null
|
|
210
|
-
if (m && !m.conflicts.length) continue // settleHeld lands it below
|
|
211
|
-
h.was = fingerprint(disk(h.path))
|
|
212
|
-
await openClash(room, key, rec, h, { dir, texts: { base: bb, ours: disk(h.path), theirs: tt }, now: started })
|
|
213
|
-
} catch (err) { log(`[${room.name}] ${key}: could not open the clash on ${h.path}: ${err.message}`) }
|
|
214
|
-
}
|
|
203
|
+
// Held files first, each against its own base and theirs: a claim let go of (and an older relay's clash) merged.
|
|
215
204
|
try {
|
|
216
205
|
const r = await settleHeld(room, key, { e, state, rec, dir, disk, bins, tooLarge, now: started, log })
|
|
217
206
|
landed += r.landed; released = r.released
|
|
@@ -225,7 +214,7 @@ export async function bringInBranch (room, key, { log = () => {}, now = () => Da
|
|
|
225
214
|
// The session is at GitHub's head: the record that folders follow (session.js memberAt) says so too.
|
|
226
215
|
if (rec.sha !== head) { rec.sha = head; rec.relayHead = head }
|
|
227
216
|
await keepRefs(dir, room.name, key, { base, others: keptObjects(state) })
|
|
228
|
-
if (!state.held.some((h) => h.why === '
|
|
217
|
+
if (!state.held.some((h) => h.why === 'claimed' || h.why === 'unread')) { said(null); closeLanded(room, key, rec, `Brought in at ${head.slice(0, 7)}: the session on ${key} is at ${rec.name} ${head.slice(0, 7)}.`) }
|
|
229
218
|
return done({ state: 'up-to-date', head, landed, released, ...(probe ? { probe } : {}) })
|
|
230
219
|
}
|
|
231
220
|
// A folder's commit that isn't on GitHub (not pushed, or dropped) is never what the session is measured from: the
|
|
@@ -286,49 +275,39 @@ export async function bringInBranch (room, key, { log = () => {}, now = () => Da
|
|
|
286
275
|
// A held file that changed again on GitHub keeps its base and takes the newest head as theirs.
|
|
287
276
|
for (const h of state.held) {
|
|
288
277
|
if (!changes.has(h.path)) continue
|
|
289
|
-
if (h.kind === 'offline') { h.upstreamMoved = { base: h.upstreamMoved ? h.upstreamMoved.base : base, theirs: head }; changes.delete(h.path); continue }
|
|
290
278
|
h.theirs = head; h.head = head; changes.delete(h.path)
|
|
291
|
-
// A proposal was for the version GitHub had: it no longer is (addendum 16.8: a changed side invalidates it).
|
|
292
|
-
if (h.candidate) {
|
|
293
|
-
h.candidate = null
|
|
294
|
-
if (h.merger && room.postChat) room.postChat({ by: RELAY_BY, to: h.merger, kind: 'nudge', text: `${h.path} changed on GitHub again (${head.slice(0, 7)}) while your merge was waiting: propose it again against the new version (quilt_clash status, and the clash task).` })
|
|
295
|
-
}
|
|
296
279
|
}
|
|
297
280
|
const heldPaths = new Set(state.held.map((h) => h.path))
|
|
298
281
|
const published = publishedOf(room.doc)
|
|
299
282
|
const why = (c) => tooLarge.has(c.path) ? { ...c, why: `too large for the relay to bring in (binaries over ${LARGE_FILE_BYTES / 1024} KB, text over ${MAX_TEXT_BYTES / 1024 / 1024} MB): a member's folder brings it in` } : c
|
|
300
|
-
|
|
301
|
-
// §7.3 step 2 (structural): what still clashes line by line is tried again ignoring whitespace, word by word, or as a union for merge=union files.
|
|
283
|
+
// Files both sides changed: who changed each last, on each side, settles which edit is the newer (combine).
|
|
302
284
|
const union = await unionFor(e, dir, head)
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const m = mergeSmart(b, o, t, { union: union(c.path) })
|
|
311
|
-
// Whitespace-only and merge=union land by themselves; a word-by-word merge is only a suggestion (addendum 16.8).
|
|
312
|
-
if (!m.conflicts.length && m.how !== 'words') settled.set(c.path, m.text)
|
|
313
|
-
else if (!m.conflicts.length) suggested.set(c.path, m.text)
|
|
314
|
-
}
|
|
315
|
-
if (settled.size) {
|
|
316
|
-
plan = planCatchUp({ changes: new Map([...changes].filter(([rel]) => !settled.has(rel))), base: baseMap, theirs, disk, published })
|
|
317
|
-
for (const [rel, t] of settled) { if (t !== disk(rel)) plan.writes.set(rel, t); structural.push(rel) }
|
|
318
|
-
}
|
|
285
|
+
const tops = new Map()
|
|
286
|
+
const parties = new Map()
|
|
287
|
+
for (const rel of changes.keys()) {
|
|
288
|
+
const b = baseMap.get(rel); const o = disk(rel); const t = theirs.get(rel)
|
|
289
|
+
if (b === undefined || o === undefined || t === undefined || o === b || o === t || t === b) continue
|
|
290
|
+
tops.set(rel, (await upstreamCommits(dir, base, head, rel))[0] || null)
|
|
291
|
+
parties.set(rel, sessionPartyOf(e, rel))
|
|
319
292
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
293
|
+
if (room.ended || room.destroyed || !room.meta.branches[key] || !current() || state.base !== base) return done({ state: 'moved-on' })
|
|
294
|
+
// A commit's time is in whole seconds: it counts as made at the end of its second, so a commit
|
|
295
|
+
// and an edit in the same second go to the commit.
|
|
296
|
+
const oursNewer = (rel) => ((parties.get(rel) || {}).ts || 0) > ((tops.get(rel) || {}).ts || 0) + 999
|
|
297
|
+
let plan = planCatchUp({ changes, base: baseMap, theirs, disk, published, oursNewer, union })
|
|
298
|
+
// Only files the relay couldn't read are left: held (never overwritten with the session's copy), and everything else lands.
|
|
299
|
+
const unread = plan.conflicts.map(why)
|
|
300
|
+
if (unread.length) {
|
|
301
|
+
plan = planCatchUp({ changes: new Map([...changes].filter(([rel]) => !unread.some((c) => c.path === rel))), base: baseMap, theirs, disk, published, oursNewer, union })
|
|
302
|
+
for (const c of unread) if (!heldPaths.has(c.path)) state.held.push({ path: c.path, why: 'unread', base, theirs: head, head, since: started })
|
|
326
303
|
}
|
|
304
|
+
// Planned against the session's content as it is now: a file edited before the writes is held and merged on the next look (below).
|
|
305
|
+
const captured = new Map([...plan.writes.keys(), ...plan.merged.map((m) => m.path)].map((rel) => [rel, disk(rel)]))
|
|
327
306
|
// A file someone holds is never written under them: held with its own base and theirs until they let go (Room.noteClaimsChanged looks again).
|
|
328
307
|
const claimed = []
|
|
329
308
|
for (const rel of plan.writes.keys()) {
|
|
330
309
|
const c = room.claimFor ? room.claimFor(rel, key) : null
|
|
331
|
-
if (c
|
|
310
|
+
if (c) claimed.push({ rel, by: c.by, pattern: c.pattern })
|
|
332
311
|
}
|
|
333
312
|
for (const { rel, by, pattern } of claimed) {
|
|
334
313
|
plan.writes.delete(rel)
|
|
@@ -339,25 +318,33 @@ export async function bringInBranch (room, key, { log = () => {}, now = () => Da
|
|
|
339
318
|
rec.waitingOn = { path: rel, by, pattern }
|
|
340
319
|
}
|
|
341
320
|
if (!claimed.length) delete rec.waitingOn
|
|
342
|
-
//
|
|
343
|
-
const
|
|
344
|
-
const
|
|
345
|
-
for (const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
mergedBy.set(rel, { session: sessionPartyOf(e, rel), github: top ? { name: top.name, email: top.email, ts: top.ts, sha: top.sha, subject: top.subject, member: memberForAuthor(room, top) } : null })
|
|
321
|
+
// The session's version of a file whose lines lost is kept in the mirror too (a binary's history has no bytes), for RESOLVED_KEEP_MS.
|
|
322
|
+
const gh = (rel) => { const top = tops.get(rel); return top ? { name: top.name, email: top.email, ts: top.ts, sha: top.sha, subject: top.subject, member: memberForAuthor(room, top) } : null }
|
|
323
|
+
const merges = plan.merged.filter((m) => !claimed.some((c) => c.rel === m.path))
|
|
324
|
+
for (const m of merges) {
|
|
325
|
+
if (!m.lost.some((l) => l.side === 'ours')) continue
|
|
326
|
+
const buf = sessionBuf(e, m.path)
|
|
327
|
+
m.oursBlob = buf ? await storeBlob(dir, buf).catch(() => null) : null
|
|
350
328
|
}
|
|
351
329
|
crashPoint(room, 'merge')
|
|
352
330
|
// Addendum 16.1: each write was planned against the session's content as it was then. A file edited since (the awaits above
|
|
353
331
|
// let edits in) is not overwritten: it is held against its base and theirs, and merged with the newer edit on the next look.
|
|
332
|
+
const raced = new Set()
|
|
354
333
|
for (const rel of [...plan.writes.keys()]) {
|
|
355
334
|
if (disk(rel) === captured.get(rel)) continue
|
|
335
|
+
raced.add(rel)
|
|
356
336
|
plan.writes.delete(rel)
|
|
357
337
|
if (!state.held.some((h) => h.path === rel)) state.held.push({ path: rel, why: 'raced', base, theirs: head, head, since: started })
|
|
358
338
|
}
|
|
359
|
-
|
|
339
|
+
const landedMerges = merges.filter((m) => !raced.has(m.path))
|
|
340
|
+
// Files both sides changed, merged on the way in: on record with both parties, so failing checks after the push tell them (relay-clash.js).
|
|
341
|
+
for (const m of landedMerges) noteMerged(room, key, m.path, { session: parties.get(m.path) || null, github: gh(m.path) }, { now: started })
|
|
360
342
|
const written = applyWrites(room, e, plan.writes, { bins, now: started })
|
|
343
|
+
// Whoever's lines lost is told (a direct message for the session's, the activity log for GitHub's); the losing version is kept.
|
|
344
|
+
for (const m of landedMerges) {
|
|
345
|
+
if (m.oursBlob) noteResolved(room, key, { path: m.path, kind: 'auto', base, theirs: head, oursBlob: m.oursBlob, how: `merged by Quilt: ${rec.name}'s newer edit kept`, at: started })
|
|
346
|
+
tellAutoMerge(room, key, { path: m.path, lost: m.lost, deleted: m.deleted || null, session: parties.get(m.path) || null, github: gh(m.path), upstream: rec.name, head, now: started })
|
|
347
|
+
}
|
|
361
348
|
landed += written
|
|
362
349
|
// The files that are now GitHub's version exactly are marked so (the Files tree, quilt_sync_status), until
|
|
363
350
|
// someone edits them in the session (server.js wireBranch).
|
|
@@ -379,7 +366,7 @@ export async function bringInBranch (room, key, { log = () => {}, now = () => Da
|
|
|
379
366
|
rec.sha = head
|
|
380
367
|
rec.relayHead = head
|
|
381
368
|
if (behind || written) rec.brought = { count: behind, files: written, at: started, head }
|
|
382
|
-
await keepRefs(dir, room.name, key, { base: head, others: state
|
|
369
|
+
await keepRefs(dir, room.name, key, { base: head, others: keptObjects(state) })
|
|
383
370
|
if (rewritten) {
|
|
384
371
|
// The session's own work on the new history: pending, so the next batch pushes it on top of the new head.
|
|
385
372
|
for (const rel of changes.keys()) if (!heldPaths.has(rel) && disk(rel) !== undefined && disk(rel) !== (theirs.has(rel) ? theirs.get(rel) : null)) state.pending[rel] = started
|
|
@@ -391,27 +378,18 @@ export async function bringInBranch (room, key, { log = () => {}, now = () => Da
|
|
|
391
378
|
room.activity.push([{ by: RELAY_BY, path: '', kind: 'brought', detail, branch: key, ts: started }])
|
|
392
379
|
if (room.activity.length > 300) room.activity.delete(0, room.activity.length - 300)
|
|
393
380
|
}, RELAY_ORIGIN)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (
|
|
398
|
-
// §7.3: each newly held file gets its parties, a merger and a reviewer, and a task (relay-clash.js).
|
|
399
|
-
for (const h of clashing) {
|
|
400
|
-
if (h.parties) continue
|
|
401
|
-
try {
|
|
402
|
-
await openClash(room, key, rec, h, { dir, texts: { base: baseMap.has(h.path) ? baseMap.get(h.path) : null, ours: disk(h.path), theirs: theirs.has(h.path) ? theirs.get(h.path) : null }, now: started })
|
|
403
|
-
if (suggested.has(h.path)) await suggestCandidate(room, key, h, suggested.get(h.path), { dir, now: started })
|
|
404
|
-
} catch (err) { log(`[${room.name}] ${key}: could not open the clash on ${h.path}: ${err.message}`) }
|
|
405
|
-
}
|
|
406
|
-
task = clashing.find((h) => h.task)?.task || null
|
|
407
|
-
said(`${plural(clashing.length, 'file')} on ${key} clash with ${rec.name} ${head.slice(0, 7)}: ${clashing.map((h) => h.path).slice(0, 4).join(', ')}${clashing.length > 4 ? ' and more' : ''} ${clashing.length === 1 ? 'is' : 'are'} held while ${clashing.map((h) => h.merger).filter(Boolean)[0] || 'an agent or a person'} merges${task ? ` (task ${task})` : ''}; everything else is in`)
|
|
408
|
-
} else if (claimed.length) {
|
|
381
|
+
const combined = landedMerges.filter((m) => m.lost.length || m.deleted)
|
|
382
|
+
log(`[${room.name}] ${key}: ${RELAY_BY} brought in ${detail} (${base.slice(0, 7)}..${head.slice(0, 7)})${combined.length ? `; combined ${combined.map((m) => `${m.path} (${m.deleted ? 'kept the edit over a deletion' : `the ${m.oursNewer ? 'session\'s' : 'GitHub\'s'} newer lines kept`})`).join(', ')}` : ''}${state.held.length ? `; held: ${state.held.map((h) => `${h.path} (${h.why})`).join(', ')}` : ''}`)
|
|
383
|
+
const unreadHeld = state.held.filter((h) => h.why === 'unread')
|
|
384
|
+
if (claimed.length) {
|
|
409
385
|
said(`${claimed[0].by} holds ${claimed[0].rel}: the relay brings ${rec.name}'s change to it in once they let go of it (quilt_release, or quilt_handoff); everything else is in`)
|
|
386
|
+
} else if (unreadHeld.length) {
|
|
387
|
+
said(`${unreadHeld.map((h) => h.path).slice(0, 4).join(', ')}${unreadHeld.length > 4 ? ' and more' : ''}: too large for the relay to read, so a member's folder brings ${unreadHeld.length === 1 ? 'it' : 'them'} in; everything else is in`)
|
|
410
388
|
} else {
|
|
411
389
|
said(null)
|
|
412
390
|
closeLanded(room, key, rec, `Brought in at ${head.slice(0, 7)}: ${RELAY_BY} merged ${plural(behind, 'commit')} from ${rec.name} into ${key}${released.length ? `, keeping the merged ${released.join(', ')}` : ''}.`)
|
|
413
391
|
}
|
|
414
|
-
return done({ state: 'brought', head, count: behind, files: written, held: state.held.map((h) => ({ path: h.path, why: h.why })),
|
|
392
|
+
return done({ state: 'brought', head, count: behind, files: written, held: state.held.map((h) => ({ path: h.path, why: h.why })), released, merged: landedMerges.map((m) => ({ path: m.path, lost: m.lost.length ? m.lost[0].side : null, ...(m.deleted ? { deleted: m.deleted } : {}) })) })
|
|
415
393
|
})
|
|
416
394
|
}
|
|
417
395
|
|
|
@@ -490,7 +468,6 @@ async function unionFor (e, dir, head) {
|
|
|
490
468
|
return unionMatcher(texts)
|
|
491
469
|
}
|
|
492
470
|
|
|
493
|
-
/** Writes `writes` (path -> key: text, "bin:<sha1>" or null) into the branch document as the relay, in one transaction; how many. */
|
|
494
471
|
/** The newest commit in base..head that touched each of `paths`: path -> { sha, by, at, subject }, in one git log. */
|
|
495
472
|
async function lastCommits (dir, base, head, paths) {
|
|
496
473
|
const want = new Set(paths)
|
|
@@ -564,7 +541,8 @@ export function fromGithubOf (e) {
|
|
|
564
541
|
return out.sort((a, b) => (b.at || 0) - (a.at || 0) || (a.path < b.path ? -1 : 1))
|
|
565
542
|
}
|
|
566
543
|
|
|
567
|
-
|
|
544
|
+
/** Writes `writes` (path -> key: text, "bin:<sha1>" or null) into the branch document as the relay, in one transaction; how many. */
|
|
545
|
+
export function applyWrites (room, e, writes, { bins, now }) {
|
|
568
546
|
if (!writes.size) return 0
|
|
569
547
|
const history = e.historyLog || (e.historyLog = new HistoryLog(e.doc, e.doc.getArray('history'), { origin: RELAY_ORIGIN }))
|
|
570
548
|
let written = 0
|
|
@@ -597,73 +575,93 @@ function applyWrites (room, e, writes, { bins, now }) {
|
|
|
597
575
|
return written
|
|
598
576
|
}
|
|
599
577
|
|
|
578
|
+
/** The session's bytes of `rel` on the branch document (text or an inline binary), or null. */
|
|
579
|
+
function sessionBuf (e, rel) {
|
|
580
|
+
const t = e.files.get(rel)
|
|
581
|
+
if (t) return Buffer.from(t.toString(), 'utf8')
|
|
582
|
+
const b = e.blobs.get(rel)
|
|
583
|
+
return b && typeof b.data === 'string' ? Buffer.from(b.data, 'base64') : null
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function readBlob (dir, sha) {
|
|
587
|
+
const r = await runGit(dir, ['cat-file', 'blob', sha], { buffer: true })
|
|
588
|
+
return r.ok ? r.out : null
|
|
589
|
+
}
|
|
590
|
+
|
|
600
591
|
/**
|
|
601
|
-
* The held files, each looked at against its own base and theirs:
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
592
|
+
* The held files, each looked at against its own base and theirs: one whose claim is gone (or that
|
|
593
|
+
* raced an edit, or an older relay held for a clash) is combined with the session's copy now
|
|
594
|
+
* (upstream.js combine) and lands, whoever's lines lost told; files an older relay held for failing
|
|
595
|
+
* checks are let go of. Returns { landed, released: [paths] }.
|
|
605
596
|
*/
|
|
606
597
|
async function settleHeld (room, key, { e, state, rec, dir, disk, bins, tooLarge, now, log }) {
|
|
607
598
|
if (!state.held.length) return { landed: 0, released: [] }
|
|
608
599
|
const union = await unionFor(e, dir, null)
|
|
609
600
|
const writes = new Map()
|
|
610
|
-
const settledBy = [] // clashes (relay-clash.js) a person settled by saving the file without markers
|
|
611
601
|
const released = []
|
|
612
602
|
const keep = []
|
|
603
|
+
const done = [] // { h, d, r, party, top, theirsName }
|
|
613
604
|
for (const h of state.held) {
|
|
614
605
|
const d = disk(h.path)
|
|
615
606
|
if (d === undefined) { keep.push(h); continue } // a large stored file: a folder brings it in
|
|
607
|
+
if (h.why === 'checks') { released.push(h.path); closeOldClash(room, key, h, { how: 'let go of: files are no longer held for failing checks', now }); continue }
|
|
608
|
+
if (h.why === 'claimed' && room.claimFor && room.claimFor(h.path, key)) { keep.push(h); continue }
|
|
609
|
+
let base, theirs, party, top, theirsName
|
|
616
610
|
if (h.kind === 'offline') {
|
|
617
|
-
//
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
if (d === theirs) { released.push(h.path); continue }
|
|
636
|
-
keep.push({ ...h, why: 'clash', was: fingerprint(d), regions: [], since: now })
|
|
637
|
-
continue
|
|
638
|
-
}
|
|
639
|
-
// A clash.
|
|
640
|
-
const settled = !!(rec.clash && rec.clash.settled && rec.clash.sha === h.head)
|
|
641
|
-
if (hasMarkers(d)) { keep.push(h); continue }
|
|
642
|
-
if (settled) { released.push(h.path); continue } // the assignee says it is merged as it is
|
|
643
|
-
if (fingerprint(d) === h.was) { keep.push(h); continue } // untouched since
|
|
644
|
-
if (isText(base) && isText(d) && isText(theirs)) {
|
|
645
|
-
const m = mergeSmart(base, d, theirs, { union: union(h.path) })
|
|
646
|
-
if (m.how === 'words' && !m.conflicts.length && fingerprint(d) === h.was) { keep.push(h); continue }
|
|
647
|
-
const old = h.regions || []
|
|
648
|
-
const stillClashing = m.conflicts.some((c) => old.includes(sha1(Buffer.from(c.ours.join('\n'), 'utf8'))))
|
|
649
|
-
if (m.conflicts.length && stillClashing) { keep.push(h); continue }
|
|
650
|
-
// The clashing lines were rewritten: they are the merge (ours, in m.text), and GitHub's other changes come in around them.
|
|
651
|
-
if (m.text !== d) writes.set(h.path, m.text)
|
|
652
|
-
released.push(h.path)
|
|
653
|
-
if (h.parties) settledBy.push(h)
|
|
654
|
-
continue
|
|
611
|
+
// An older relay's clash with a member's offline edits: theirs is what they brought back.
|
|
612
|
+
base = h.baseBlob ? (await readBlob(dir, h.baseBlob))?.toString('utf8') ?? null : null
|
|
613
|
+
theirs = h.otherDeleted ? null : h.otherBlob ? (await readBlob(dir, h.otherBlob))?.toString('utf8') : undefined
|
|
614
|
+
if (theirs === undefined) { released.push(h.path); closeOldClash(room, key, h, { how: 'the session\'s version kept (the other is no longer kept)', now }); continue }
|
|
615
|
+
const off = (h.parties && h.parties.offline) || {}
|
|
616
|
+
party = sessionPartyOf(e, h.path)
|
|
617
|
+
top = { name: off.name || 'a member', member: off.name || null, ts: off.ts || 0 }
|
|
618
|
+
theirsName = `${off.name || 'a member'}'s offline edits`
|
|
619
|
+
} else {
|
|
620
|
+
if (!h.base || !h.theirs) { keep.push(h); continue }
|
|
621
|
+
const [b, t] = await Promise.all([filesAt(dir, h.base, [h.path]), filesAt(dir, h.theirs, [h.path])])
|
|
622
|
+
base = keyOf(b.get(h.path), h.path, { bins, tooLarge })
|
|
623
|
+
theirs = keyOf(t.get(h.path), h.path, { bins, tooLarge })
|
|
624
|
+
if (base === undefined || theirs === undefined) { keep.push(h); continue }
|
|
625
|
+
const c = (await upstreamCommits(dir, h.base, h.theirs, h.path))[0] || null
|
|
626
|
+
top = c ? { name: c.name, email: c.email, ts: c.ts, sha: c.sha, subject: c.subject, member: memberForAuthor(room, c) } : null
|
|
627
|
+
party = sessionPartyOf(e, h.path)
|
|
628
|
+
theirsName = rec.name
|
|
655
629
|
}
|
|
656
|
-
|
|
657
|
-
|
|
630
|
+
const oursNewer = ((party || {}).ts || 0) > ((top || {}).ts || 0) + 999 // a commit's whole second is its own
|
|
631
|
+
const r = combine(base, d, theirs, { oursNewer, union: union(h.path) })
|
|
632
|
+
done.push({ h, d, r, party, top, theirsName, both: d !== base && theirs !== base && d !== theirs })
|
|
633
|
+
}
|
|
634
|
+
// The session's version of a file whose lines lose is kept in the mirror too.
|
|
635
|
+
for (const x of done) {
|
|
636
|
+
if (!x.r.lost.some((l) => l.side === 'ours')) continue
|
|
637
|
+
const buf = sessionBuf(e, x.h.path)
|
|
638
|
+
x.blob = buf ? await storeBlob(dir, buf).catch(() => null) : null
|
|
639
|
+
}
|
|
640
|
+
// Each was combined against the session's copy as it was then: one edited since (the awaits let edits in) waits for the next look.
|
|
641
|
+
for (const x of done) {
|
|
642
|
+
if (disk(x.h.path) !== x.d) { keep.push(x.h); continue }
|
|
643
|
+
if (x.r.key !== x.d) writes.set(x.h.path, x.r.key)
|
|
644
|
+
released.push(x.h.path)
|
|
645
|
+
if (x.blob) noteResolved(room, key, { path: x.h.path, kind: 'auto', base: x.h.base || null, theirs: x.h.theirs || null, oursBlob: x.blob, how: `merged by Quilt: ${x.theirsName}'s newer edit kept`, at: now })
|
|
658
646
|
}
|
|
659
647
|
const landed = applyWrites(room, e, writes, { bins, now })
|
|
660
648
|
state.held = keep
|
|
661
|
-
for (const
|
|
662
|
-
|
|
663
|
-
|
|
649
|
+
for (const x of done) {
|
|
650
|
+
if (!released.includes(x.h.path)) continue
|
|
651
|
+
const { h, r } = x
|
|
652
|
+
if (h.why === 'clash' || h.kind === 'offline') closeOldClash(room, key, h, { how: 'merged by Quilt on its own', now })
|
|
653
|
+
if (h.kind === 'offline' && h.upstreamMoved) state.held.push({ path: h.path, why: 'raced', base: h.upstreamMoved.base, theirs: h.upstreamMoved.theirs, head: h.upstreamMoved.theirs, since: now })
|
|
654
|
+
if (h.kind === 'offline') {
|
|
655
|
+
// The returning member's lines lost: they are told as the session's side would be.
|
|
656
|
+
const lostTheirs = r.lost.filter((l) => l.side === 'theirs')
|
|
657
|
+
if (lostTheirs.length && x.top && x.top.member && room.postChat) room.postChat({ by: RELAY_BY, to: x.top.member, kind: 'nudge', text: `Quilt merged your offline edits to ${h.path} on ${key}: where you and ${(x.party && x.party.name) || 'the session'} both changed the same lines, the newer edit stays. Your lines it replaced:\n${lostLines(lostTheirs)}` })
|
|
658
|
+
tellAutoMerge(room, key, { path: h.path, lost: r.lost.filter((l) => l.side === 'ours'), deleted: r.deleted === 'ours' ? 'ours' : null, session: x.party, github: x.top, upstream: x.theirsName, now })
|
|
659
|
+
} else {
|
|
660
|
+
if (x.both) noteMerged(room, key, h.path, { session: x.party || null, github: x.top }, { now })
|
|
661
|
+
tellAutoMerge(room, key, { path: h.path, lost: r.lost, deleted: r.deleted || null, session: x.party, github: x.top, upstream: x.theirsName, head: h.theirs, now })
|
|
662
|
+
}
|
|
664
663
|
}
|
|
665
664
|
if (released.length) log(`[${room.name}] ${key}: ${released.join(', ')} merged and let go of`)
|
|
666
|
-
if (!keep.some((h) => h.why === 'clash') && rec.clash) { /* closeLanded by the caller once the head is current */ }
|
|
667
665
|
return { landed, released }
|
|
668
666
|
}
|
|
669
667
|
|
|
@@ -687,7 +685,7 @@ export function crashPoint (room, name) {
|
|
|
687
685
|
* Where branch `key` stands with GitHub, for people and agents (addendum 16.13): { state, why?,
|
|
688
686
|
* revision, pending, held, pr? }. `state`: 'paused' (the owner's switch, a deletion on GitHub, or a
|
|
689
687
|
* sync not set up), 'permission-held' (GitHub refused the relay), 'offline' (GitHub out of reach),
|
|
690
|
-
* '
|
|
688
|
+
* 'pr-pending' (work going through a pull request: a
|
|
691
689
|
* protected branch, or another session holds it), 'github-pending' (edits not on GitHub yet) or
|
|
692
690
|
* 'synced'. 'github-pending' means files changed in the session since its last commit (nothing goes to GitHub until
|
|
693
691
|
* someone commits). `revision`: the GitHub commit the session's content is acknowledged at.
|
|
@@ -712,7 +710,6 @@ export function syncStatus (room, key, { live = true } = {}) {
|
|
|
712
710
|
if (rec.gone) return { ...out, state: 'paused', why: rec.problem || `${rec.ref} was deleted on GitHub` }
|
|
713
711
|
if (st.paused && st.paused.why === 'unreachable') return { ...out, state: 'offline', why: rec.problem || 'GitHub out of reach', since: st.paused.at }
|
|
714
712
|
if (st.paused) return { ...out, state: 'permission-held', why: rec.problem || 'GitHub refused the relay' }
|
|
715
|
-
if (out.held) return { ...out, state: 'conflict-held', why: `${out.held} file${out.held === 1 ? '' : 's'} held for a merge` }
|
|
716
713
|
if (st.protected && st.protected.pr) return { ...out, state: 'pr-pending', why: rec.problem || '', pr: st.protected.pr, live: st.protected.head || null }
|
|
717
714
|
if (out.pending || st.op) return { ...out, state: 'github-pending' }
|
|
718
715
|
return { ...out, state: 'synced' }
|