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.
Files changed (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +131 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +141 -5
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +143 -17
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +120 -10
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +136 -27
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. 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. Files that merge cleanly land at once; a file whose
5
- // lines overlap, or that a member holds, is held aside with its own base and theirs (so a later
6
- // head never reinterprets it against another ancestor) and everything else lands. The session
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 { openClash, reviewClashes, finishClash, noteMerged, upstreamCommits, memberForAuthor, suggestCandidate } from './relay-clash.js'
12
- import { upstreamsOf, handClash, closeLanded, ignoreOf, docKey, fingerprint, hasMarkers, clashRegions, parseRepo, catcherAble, RELAY_ORIGIN } from './relay-upstream.js'
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 { mergeAdditive, mergeSmart, unionMatcher } from './merge3.js'
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; files that clash or that a member holds are held with their own base and
120
- * theirs, and looked at again each time. Returns what happened: { state, ... } (for tests and the
121
- * log). Never throws.
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
- // The open clashes: proposals nobody reviewed, mergers gone quiet, nobody to merge (relay-clash.js).
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 === 'clash')) { 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)}.`) }
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
- let plan = planCatchUp({ changes, base: baseMap, theirs, disk, published })
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 structural = []
304
- const suggested = new Map()
305
- const settled = new Map() // structural merges that land by themselves: kept through any re-plan below
306
- if (plan.conflicts.length) {
307
- for (const c of plan.conflicts) {
308
- const b = baseMap.get(c.path); const o = disk(c.path); const t = theirs.get(c.path)
309
- if (!isText(b) || !isText(o) || !isText(t)) continue
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
- const conflicts = plan.conflicts.map(why)
321
- if (conflicts.length) {
322
- // Those files are held; everything else lands.
323
- plan = planCatchUp({ changes: new Map([...changes].filter(([rel]) => !conflicts.some((c) => c.path === rel) && !settled.has(rel))), base: baseMap, theirs, disk, published })
324
- for (const [rel, t] of settled) if (t !== disk(rel)) plan.writes.set(rel, t)
325
- for (const c of conflicts) if (!heldPaths.has(c.path)) state.held.push({ path: c.path, why: 'clash', base, theirs: head, head, was: fingerprint(disk(c.path)), regions: clashRegions(baseMap.get(c.path), disk(c.path), theirs.get(c.path)) || [], since: started })
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 && !(rec.clash && c.clash === rec.clash.task)) claimed.push({ rel, by: c.by, pattern: c.pattern })
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
- // Files both sides changed, merged on the way in: on record with both parties, so failing checks after the push can pull them in (relay-clash.js).
343
- const captured = new Map([...plan.writes.keys()].map((rel) => [rel, disk(rel)]))
344
- const mergedBy = new Map()
345
- for (const [rel, k] of plan.writes) {
346
- const b = baseMap.get(rel); const o = captured.get(rel); const t = theirs.get(rel)
347
- if (!(isText(k) && isText(b) && isText(o) && isText(t) && o !== b && t !== b && k !== t)) continue
348
- const top = (await upstreamCommits(dir, base, head, rel))[0] || null
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
- for (const [rel, parties] of mergedBy) if (plan.writes.has(rel)) noteMerged(room, key, rel, parties, { now: started })
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.held.flatMap((h) => [h.base, h.theirs]) })
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
- log(`[${room.name}] ${key}: ${RELAY_BY} brought in ${detail} (${base.slice(0, 7)}..${head.slice(0, 7)})${state.held.length ? `; held: ${state.held.map((h) => `${h.path} (${h.why})`).join(', ')}` : ''}`)
395
- const clashing = state.held.filter((h) => h.why === 'clash')
396
- let task = null
397
- if (clashing.length) {
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 })), task, released, structural })
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
- function applyWrites (room, e, writes, { bins, now }) {
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: a claimed file whose claim is
602
- * gone merges and lands (or becomes a clash); a clashing file lands once the session's copy
603
- * merges cleanly with its theirs, or its task was settled (moved on by its assignee) without
604
- * markers left. Returns { landed, released: [paths] }.
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
- // A person saved it without markers in their own editor (agents can't write a held file): that settles it.
618
- if (fingerprint(d) !== h.was && !hasMarkers(d)) { released.push(h.path); settledBy.push(h) } else keep.push(h)
619
- continue
620
- }
621
- if (!h.base || !h.theirs) { keep.push(h); continue }
622
- const [b, t] = await Promise.all([filesAt(dir, h.base, [h.path]), filesAt(dir, h.theirs, [h.path])])
623
- const base = keyOf(b.get(h.path), h.path, { bins, tooLarge })
624
- const theirs = keyOf(t.get(h.path), h.path, { bins, tooLarge })
625
- if (h.why === 'claimed' || h.why === 'raced') {
626
- const c = h.why === 'claimed' && room.claimFor ? room.claimFor(h.path, key) : null
627
- if (c && !(rec.clash && c.clash === rec.clash.task)) { keep.push(h); continue }
628
- if (d === base || (d === null && base === null)) { writes.set(h.path, theirs); released.push(h.path); continue } // nobody touched it meanwhile
629
- if (isText(base) && isText(d) && isText(theirs)) {
630
- const m = mergeSmart(base, d, theirs, { union: union(h.path) })
631
- if (!m.conflicts.length) { if (m.text !== d) writes.set(h.path, m.text); released.push(h.path); continue }
632
- keep.push({ ...h, why: 'clash', was: fingerprint(d), regions: m.conflicts.map((c) => sha1(Buffer.from(c.ours.join('\n'), 'utf8'))), since: now })
633
- continue
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
- released.push(h.path) // a deletion or a binary: changed since is all there is to go on
657
- if (h.parties) settledBy.push(h)
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 h of settledBy) {
662
- const last = e.doc.getArray('history').toArray().filter((x) => x && x.path === h.path && !x.pulled && x.by && !isRelay(x.by)).sort((a, b) => (a.ts || 0) - (b.ts || 0)).at(-1)
663
- finishClash(room, key, h, { how: 'merged in an editor', by: last ? last.by : 'a person', text: docKey(e, h.path), now, mergeHow: 'agent' })
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
- * 'conflict-held' (files held for a merge), 'pr-pending' (work going through a pull request: a
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' }