heyquilt 0.3.121 → 0.3.138

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 (129) hide show
  1. package/RELEASES.md +292 -17
  2. package/bin/quilt.js +72 -5
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -1
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +21 -10
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/seam-connect.js +103 -0
  15. package/src/api/routes/session-migrate.js +39 -0
  16. package/src/api/routes/sessions.js +73 -1
  17. package/src/api/server.js +46 -12
  18. package/src/api/supabase-store.js +53 -4
  19. package/src/autowork.js +182 -16
  20. package/src/connection.js +39 -9
  21. package/src/control.js +139 -13
  22. package/src/force-date.js +27 -0
  23. package/src/fsutil.js +13 -3
  24. package/src/gitstate.js +38 -2
  25. package/src/history.js +25 -1
  26. package/src/mcp.js +93 -7
  27. package/src/merge3.js +11 -3
  28. package/src/now-summary.js +113 -0
  29. package/src/pathrules.js +12 -0
  30. package/src/protocol.js +10 -2
  31. package/src/quilt-default.js +84 -0
  32. package/src/quilt-history.js +106 -0
  33. package/src/quilt-remote.js +78 -0
  34. package/src/relay-commit.js +5 -0
  35. package/src/relay-lease.js +6 -2
  36. package/src/relay-mcp.js +172 -8
  37. package/src/relay-outbound.js +1 -1
  38. package/src/relay-prune.js +4 -1
  39. package/src/release-words.js +2 -2
  40. package/src/runner.js +20 -7
  41. package/src/seam-access.js +97 -0
  42. package/src/seam-claims.js +53 -0
  43. package/src/seam-url.js +15 -0
  44. package/src/server.js +1767 -33
  45. package/src/session-follow.js +515 -0
  46. package/src/session-layout3.js +466 -0
  47. package/src/session-migrate.js +123 -0
  48. package/src/session-quilt.js +145 -0
  49. package/src/session-seam.js +284 -0
  50. package/src/session.js +899 -46
  51. package/src/source-live/bring-in.js +283 -0
  52. package/src/source-live/live-block.js +507 -35
  53. package/src/source-live/live-square.js +17 -6
  54. package/src/status.js +5 -0
  55. package/src/ui/agent-guide.js +1 -1
  56. package/src/ui/app.css +85 -20
  57. package/src/ui/app.js +4 -1
  58. package/src/ui/chat.js +9 -3
  59. package/src/ui/cloud-ai-model.js +13 -0
  60. package/src/ui/cloud-ai.js +10 -2
  61. package/src/ui/common.js +6 -2
  62. package/src/ui/fileview.js +20 -7
  63. package/src/ui/force-words.js +19 -0
  64. package/src/ui/git-rule.js +9 -0
  65. package/src/ui/home.js +0 -147
  66. package/src/ui/loom-model.js +15 -1
  67. package/src/ui/quiltview.js +213 -0
  68. package/src/ui/seam-model.js +251 -0
  69. package/src/ui/session.js +413 -31
  70. package/src/ui/voice-call.js +83 -14
  71. package/src/ui/voice-model.js +39 -0
  72. package/src/ui/voice.js +167 -17
  73. package/src/ui-server.js +80 -28
  74. package/src/update-check.js +25 -1
  75. package/src/voice.js +13 -6
  76. package/src/git.js +0 -147
  77. package/src/quilts/AGENTS.md +0 -1
  78. package/src/quilts/CLAUDE.md +0 -1
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/claims.js +0 -144
  87. package/src/quilts/cli.js +0 -449
  88. package/src/quilts/client.js +0 -128
  89. package/src/quilts/conflict-format.js +0 -190
  90. package/src/quilts/describe.js +0 -266
  91. package/src/quilts/dogfood.js +0 -82
  92. package/src/quilts/errors.js +0 -8
  93. package/src/quilts/events.js +0 -160
  94. package/src/quilts/export-git.js +0 -130
  95. package/src/quilts/export.js +0 -81
  96. package/src/quilts/fastmerge.js +0 -41
  97. package/src/quilts/hash.js +0 -44
  98. package/src/quilts/history.js +0 -266
  99. package/src/quilts/ids.js +0 -12
  100. package/src/quilts/import-git.js +0 -291
  101. package/src/quilts/import.js +0 -134
  102. package/src/quilts/index.js +0 -72
  103. package/src/quilts/limits.js +0 -67
  104. package/src/quilts/lines.js +0 -49
  105. package/src/quilts/live.js +0 -50
  106. package/src/quilts/merge-smart.js +0 -114
  107. package/src/quilts/merge.js +0 -486
  108. package/src/quilts/objects.js +0 -144
  109. package/src/quilts/pack.js +0 -118
  110. package/src/quilts/records.js +0 -211
  111. package/src/quilts/resolutions.js +0 -69
  112. package/src/quilts/reviews.js +0 -195
  113. package/src/quilts/routes.js +0 -126
  114. package/src/quilts/service.js +0 -923
  115. package/src/quilts/setup.js +0 -34
  116. package/src/quilts/signing.js +0 -149
  117. package/src/quilts/squares.js +0 -239
  118. package/src/quilts/store-memory.js +0 -683
  119. package/src/quilts/store-rpc.js +0 -79
  120. package/src/quilts/store-supabase.js +0 -13
  121. package/src/quilts/tags.js +0 -64
  122. package/src/quilts/textdiff.js +0 -96
  123. package/src/quilts/tokens.js +0 -66
  124. package/src/quilts/tools.js +0 -375
  125. package/src/quilts/tree.js +0 -351
  126. package/src/quilts/webhook.js +0 -116
  127. package/src/quilts/window.js +0 -119
  128. package/src/quilts/xdiff.js +0 -435
  129. package/src/ui/branches.js +0 -111
@@ -1,486 +0,0 @@
1
- // Merge, rebase, cherry-pick, revert and resolve on the server (plans/quilt-source-control/07).
2
- // A merge into a line rebases the square's head onto the tip as one commit and moves the line
3
- // by compare-and-swap; a lost race is retried here, never by the client. A textual conflict
4
- // never reaches a line: the merge is refused and, by default, the conflicted rebase becomes the
5
- // square's head with the conflicts kept as data (conflict terms), so the square carries on.
6
- import { recall, remember } from './resolutions.js'
7
- import { fastMerge as mergeSmart } from './fastmerge.js' // mergeSmart (src/merge3.js) behind a fast clean path
8
- import { cleanCommit, commitBytes, commitId, blobId, termBytes, termId, cleanPath } from './records.js'
9
- import { sameEntry } from './tree.js'
10
- import { isText } from './textdiff.js'
11
- import { QuiltError, uuidv7, squareShort } from './service.js'
12
-
13
- const MAX_ATTEMPTS = 5
14
- // The highest ladder step that merges by itself (07: auto_combine, default 'whitespace'); words never do.
15
- const AUTO = new Set(['lines', 'additive', 'whitespace', 'union'])
16
- const MAX_SIDES = 8
17
-
18
- // The line's merge lease (07 step 8): a merge that lost three races queues for the line's lease
19
- // and makes its next attempts holding it, one holder at a time in arrival order; every merge
20
- // that holds no lease waits while it is held before reading the tip. So a holder can lose only
21
- // to attempts already running, and every merge lands. Two layers: an in-process queue keeps
22
- // arrival order on one API machine, and the store's line lease (quilt_line_leases) is the lock
23
- // across machines. A non-holder peeks at the table lease with ttl 0 (takes it only if free, and
24
- // then it is already expired). A crashed holder's lease lapses after LEASE_TTL_MS.
25
- const LEASE_TTL_MS = 10_000
26
- const leases = new Map() // quilt + line → { held: boolean, queue: [resolve] }
27
-
28
- function leaseOf (key) { let l = leases.get(key); if (!l) { l = { held: false, queue: [], idle: [] }; leases.set(key, l) } return l }
29
- function takeLease (key) {
30
- const l = leaseOf(key)
31
- if (!l.held) { l.held = true; return Promise.resolve() }
32
- return new Promise((resolve) => l.queue.push(resolve))
33
- }
34
- function giveLease (key) {
35
- const l = leaseOf(key)
36
- const next = l.queue.shift()
37
- if (next) return next()
38
- l.held = false
39
- for (const wake of l.idle.splice(0)) wake()
40
- }
41
- async function waitLease (key) {
42
- const l = leaseOf(key)
43
- while (l.held) await new Promise((resolve) => l.idle.push(resolve))
44
- }
45
-
46
- const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
47
-
48
- export function mergeOps (svc) {
49
- const { store, now, conflictsAt, quiltFor, squareByRef, resolve, getCommit, snapshot, entriesOf, treeGrower, reader, seal, readBlob, squareView, commitView, writable, once, getTerm, fromStore } = svc.internals
50
-
51
- /** The entries of `paths` in a commit's tree (only the nodes on their way are read). */
52
- const entryMap = async (q, id, paths) => {
53
- const root = await snapshot(q, id)
54
- const m = new Map()
55
- for (const p of paths) { const e = await reader(q).lookup(root, p); if (e) m.set(p, e) }
56
- return m
57
- }
58
-
59
- /**
60
- * Three-way combination of trees: for every path either side changed from `base`, the final
61
- * entry (taken, merged by content, or a conflict term). Returns { finals: Map path → entry|null,
62
- * blobs (new merged blobs), terms (term objects), combined, conflicts: [{ path, kind, term }] }.
63
- * `sides` names who and what each side is, for the terms: { ours: {commit, square, by}, theirs }.
64
- */
65
- async function combine (q, baseId, oursId, theirsId, sides, origin) {
66
- const [B, O, T] = [await snapshot(q, baseId), await snapshot(q, oursId), await snapshot(q, theirsId)]
67
- const r = reader(q)
68
- const [dO, dT] = [await r.diff(B, O), await r.diff(B, T)]
69
- const oursPaths = new Map(dO.map((d) => [d.path, d]))
70
- const theirsPaths = new Map(dT.map((d) => [d.path, d]))
71
- const finals = new Map()
72
- const blobs = []; const terms = []; const combined = []; const conflicts = []
73
- const overlap = []
74
- for (const [p, d] of oursPaths) if (!theirsPaths.has(p)) finals.set(p, d.after || null)
75
- for (const [p, d] of theirsPaths) if (!oursPaths.has(p)) finals.set(p, d.after || null)
76
- for (const [p] of oursPaths) if (theirsPaths.has(p)) overlap.push(p)
77
- // A path one side changed that is a directory of a path the other side changed (07 step 2).
78
- const dirClash = []
79
- // And two different paths that fold to one name on a case-insensitive disk (07 step 2).
80
- const folded = new Map()
81
- for (const t of theirsPaths.keys()) { const f = t.toLowerCase(); folded.set(f, [...(folded.get(f) || []), t]) }
82
- for (const p of oursPaths.keys()) for (const t of folded.get(p.toLowerCase()) || []) if (p !== t && theirsPaths.get(t).after && oursPaths.get(p).after) dirClash.push([p, t])
83
- for (const p of oursPaths.keys()) for (const t of theirsPaths.keys()) if (p !== t && (t.startsWith(p + '/') || p.startsWith(t + '/'))) dirClash.push([p, t])
84
-
85
- const side = (e, s) => ({ ...(e && e.blob ? { blob: e.blob, mode: e.mode } : {}), from_commit: s.commit, ...(s.square ? { from_square: s.square } : {}), ...(s.by ? { by: s.by } : {}) })
86
- const conflict = async (p, kind, o, a, b, suggestion = null, clash = null) => {
87
- const term = { kind, adds: [side(a, sides.ours), side(b, sides.theirs)], removes: [side(o, sides.base || {})], origin, ...(suggestion ? { suggestion } : {}), ...(clash ? { clash } : {}) }
88
- // The same conflict settled before (07, D12): apply the remembered answer and say so.
89
- const known = !(a && a.mode === 'conflict') && !(b && b.mode === 'conflict') ? await recall(store, q, p, term) : null
90
- if (known) {
91
- finals.set(p, known.blob ? { path: p, mode: known.mode || 'file', blob: known.blob, size: known.size } : null)
92
- combined.push({ path: p, how: 'reuse', by: known.by })
93
- return
94
- }
95
- const id = termId(term)
96
- terms.push({ kind: 'term', id, bytes: termBytes(term) })
97
- finals.set(p, { path: p, mode: 'conflict', term: id })
98
- conflicts.push({ path: p, kind, term: id, ...(suggestion ? { suggestion } : {}) })
99
- }
100
-
101
- for (const p of overlap) {
102
- const o = oursPaths.get(p).before || null
103
- const a = oursPaths.get(p).after || null
104
- const b = theirsPaths.get(p).after || null
105
- if (sameOrBoth(a, b)) { finals.set(p, a); continue }
106
- if (!a || !b) { await conflict(p, 'edit_vs_delete', o, a, b); continue }
107
- if (a.mode === 'conflict' || b.mode === 'conflict') { await conflict(p, 'content', o, a, b); continue }
108
- // Modes: the side that changed it wins; both changed it differently is a mode term.
109
- const mode = a.mode === b.mode ? a.mode : (o && a.mode === o.mode) ? b.mode : (o && b.mode === o.mode) ? a.mode : null
110
- if (a.blob === b.blob) { if (mode) finals.set(p, { ...a, mode }); else await conflict(p, 'mode', o, a, b); continue }
111
- const [ob, ab, bb] = [o && o.blob ? await readBlob(q, o.blob) : Buffer.alloc(0), await readBlob(q, a.blob), await readBlob(q, b.blob)]
112
- if (!isText(ob) || !isText(ab) || !isText(bb)) { await conflict(p, 'content', o, a, b); continue }
113
- const m = mergeSmart(ob.toString('utf8'), ab.toString('utf8'), bb.toString('utf8'))
114
- if (!m.conflicts.length && m.how === 'words') {
115
- // 07: a word merge is a suggestion only; the term carries the text it made.
116
- const sb = Buffer.from(m.text, 'utf8')
117
- const sid = blobId(sb)
118
- blobs.push({ kind: 'blob', id: sid, bytes: sb })
119
- await conflict(p, 'content', o, a, b, { how: 'words', blob: sid })
120
- continue
121
- }
122
- // Both sides inserted at one place the base had nothing and no step settles it: `order`.
123
- if (m.conflicts.length && o && m.conflicts.every((c) => c.base.length === 0)) { await conflict(p, 'order', o, a, b); continue }
124
- if (m.conflicts.length || !AUTO.has(m.how)) { await conflict(p, o ? 'content' : 'name', o, a, b); continue }
125
- if (!mode) { await conflict(p, 'mode', o, a, b); continue }
126
- const bytes = Buffer.from(m.text, 'utf8')
127
- const id = blobId(bytes)
128
- blobs.push({ kind: 'blob', id, bytes })
129
- finals.set(p, { path: p, mode, blob: id, size: bytes.length })
130
- combined.push({ path: p, how: m.how })
131
- }
132
- const clashes = new Map()
133
- for (const [p, t] of dirClash) clashes.set(p, [...(clashes.get(p) || []), t])
134
- for (const [p, others] of clashes) {
135
- const e = finals.get(p)
136
- if (e && e.mode !== 'conflict') await conflict(p, 'name', oursPaths.get(p)?.before || theirsPaths.get(p)?.before || null, oursPaths.get(p)?.after || null, theirsPaths.get(p)?.after || null, null, others)
137
- }
138
- return { finals, blobs, terms, combined, conflicts, oursPaths, theirsPaths, overlap }
139
- }
140
-
141
- /** FileChanges that turn the tree `onto` (a Map) into it with `finals` applied. */
142
- function changesOnto (onto, finals) {
143
- const out = []
144
- for (const [p, e] of finals) {
145
- const cur = onto.get(p) || null
146
- if (!e && !cur) continue
147
- if (e && cur && sameEntry(e, cur)) continue
148
- if (!e) out.push({ path: p, op: 'delete' })
149
- else if (e.mode === 'conflict') out.push({ path: p, op: 'conflict', term: e.term })
150
- else out.push({ path: p, op: 'put', blob: e.blob, mode: e.mode, size: e.size })
151
- }
152
- return out
153
- }
154
-
155
- const sameOrBoth = (a, b) => (!a && !b) || (a && b && sameEntry(a, b))
156
-
157
- /** A new commit record from parts; returns { record, id, objects } ready to seal. */
158
- function makeCommit (q, sq, { parents, predecessor, author, onBehalfOf, message, changes, stack }) {
159
- const c = cleanCommit({ quilt_id: q, square_id: sq.id, parents, ...(predecessor ? { predecessor } : {}), author, ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}), ...(sq.task_id ? { task_id: sq.task_id } : {}), message, authored_at: now(), changes, stack })
160
- const id = commitId(c)
161
- return { record: c, id, object: { kind: 'commit', id, bytes: commitBytes(c) } }
162
- }
163
-
164
- /**
165
- * Rebases a square's head onto `ontoId` as one commit: returns { commit, root, objects,
166
- * combined, conflicts, fast } without writing anything but objects. The fast path (no path
167
- * changed on both sides) re-parents the square's changes and reads no file (07 step 2).
168
- */
169
- async function rebaseCommit (q, sq, head, ontoId, { message, reason = 'rebase' } = {}) {
170
- const headCommit = await getCommit(q, head)
171
- const sides = {
172
- ours: { commit: head, square: sq.id, by: headCommit.author.member_id },
173
- theirs: { commit: ontoId, by: 'line' },
174
- base: { commit: sq.base }
175
- }
176
- const comb = await combine(q, sq.base, head, ontoId, sides, { reason, against: ontoId })
177
- const ontoRoot = await snapshot(q, ontoId)
178
- const onto = await entryMap(q, ontoId, comb.finals.keys())
179
- const changes = changesOnto(onto, comb.finals)
180
- const made = makeCommit(q, sq, { parents: [ontoId].filter(Boolean), predecessor: head, author: headCommit.author, onBehalfOf: headCommit.on_behalf_of, message: message ?? headCommit.message, changes, stack: headCommit.stack })
181
- const tree = await treeGrower(q)(ontoRoot, changes)
182
- return { made, root: tree.root, objects: [...comb.blobs, ...comb.terms, ...tree.objects, made.object], combined: comb.combined, conflicts: comb.conflicts, fast: !comb.overlap.length }
183
- }
184
-
185
- /** The patch message by default: the square's title and its commit messages (07). */
186
- async function defaultMessage (q, sq) {
187
- const commits = (await store.squareCommits(q, sq.id)).filter((c) => !c.hidden_seq)
188
- const msgs = [...new Set(commits.map((c) => String(c.message || '').split('\n')[0]).filter(Boolean))]
189
- return msgs.length === 1 && msgs[0] === sq.title ? sq.title : [sq.title, '', ...msgs.map((m) => `- ${m}`)].join('\n').trim()
190
- }
191
-
192
- /**
193
- * Merge a square into a line (07's MergeRequest): { square, expect_head, message?,
194
- * on_conflict: 'rebase' | 'leave', dry_run, expect_tip? }.
195
- */
196
- async function mergeIntoLine (me, ref, lineName, body, { reverts } = {}) {
197
- const q = await quiltFor(me, ref, 'write')
198
- let sq = await writable(me, q, body.square || body.square_id)
199
- // A square left 'merging' by a server that died mid-merge goes back to 'open' once its lease lapsed (07).
200
- if (sq.state === 'merging' && await sweepOne(sq)) sq = await store.getSquare(sq.id)
201
- const line = lineName || sq.line
202
- const expect = body.expect_head === undefined ? sq.head : body.expect_head
203
- const run = async () => {
204
- const ln = await store.getLine(q.id, line)
205
- if (!ln) throw new QuiltError(404, 'not_found', `no line ${line}`)
206
- if (ln.frozen) throw new QuiltError(423, 'line_frozen', `${line} is frozen`)
207
- const parent = await openParent(sq)
208
- if (parent) throw new QuiltError(409, 'parent_open', `this square is stacked on ${squareShort(parent.id)}, which has not merged: merge it first, or fold this square into it`, { parent: squareShort(parent.id) })
209
- if (sq.state !== 'open') throw new QuiltError(409, sq.state === 'merging' ? 'square_merging' : 'square_closed', `the square is ${sq.state}`)
210
- if ((sq.head || null) !== (expect || null)) throw new QuiltError(409, 'head_moved', 'the square moved on since you looked', { head: sq.head })
211
- if (!sq.head) throw new QuiltError(400, 'bad_request', 'the square has no commits to merge')
212
- // The line's review rule (13); an automatic revert of a red patch does not wait for approvals.
213
- if (!reverts && svc.internals.guardReview) await svc.internals.guardReview(q.id, sq, line)
214
- const held = await conflictsAt(q.id, sq.head)
215
- if (held.length) throw new QuiltError(422, 'conflict', `the square holds ${held.length} conflicted path${held.length === 1 ? '' : 's'} (${held.map((e) => e.path).join(', ')}); resolve them first`, { head: sq.head, paths: held.map((e) => e.path) })
216
- const message = body.message || await defaultMessage(q.id, sq)
217
- const warnings = []
218
- if (body.expect_tip && body.expect_tip !== ln.tip) warnings.push({ kind: 'behind_by', detail: 'patches merged since you looked' })
219
-
220
- if (body.dry_run) {
221
- const r = await rebaseCommit(q.id, sq, sq.head, ln.tip, { message })
222
- return r.conflicts.length
223
- ? { ok: false, dry_run: true, refused: { code: 'conflict', detail: `would conflict in ${r.conflicts.map((c) => c.path).join(', ')}` }, terms: r.conflicts, warnings }
224
- : { ok: true, dry_run: true, would_merge: { line, patch_no: ln.patch_count + 1, fast: r.fast }, combined: r.combined, warnings }
225
- }
226
-
227
- if (svc.internals.mergeWindow) await svc.internals.mergeWindow(q, line) // 429 merge_window above the line's window (07)
228
- try { await store.beginMerge({ square_id: sq.id, expect_head: sq.head, actor: me.member_id, at: now() }) } catch (err) { throw fromStore(err) }
229
- let retries = 0
230
- const leaseKey = `${q.id}\u0000${line}`
231
- const holder = `merge:${sq.id}:${uuidv7()}`
232
- const tableLease = (ttl) => store.takeLease({ kind: 'line', quilt_id: q.id, line, holder, ttl_ms: ttl, at: now() })
233
- let release = null
234
- try {
235
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
236
- if (attempt === 4) {
237
- await takeLease(leaseKey)
238
- release = async () => { giveLease(leaseKey); await store.giveLease({ kind: 'line', quilt_id: q.id, line, holder }).catch(() => {}) }
239
- while (!(await tableLease(LEASE_TTL_MS)).held) await pause(10 + Math.random() * 15)
240
- }
241
- if (!release) {
242
- await waitLease(leaseKey)
243
- const until = Date.now() + LEASE_TTL_MS
244
- while (!(await tableLease(0)).held && Date.now() < until) await pause(10 + Math.random() * 15)
245
- }
246
- const tip = (await store.getLine(q.id, line)).tip
247
- const r = await rebaseCommit(q.id, sq, sq.head, tip, { message })
248
- if (r.conflicts.length) {
249
- // Refused (D10). With on_conflict 'rebase' the conflicted rebase is the new head.
250
- let newHead = sq.head
251
- await seal(q.id, r.objects, { by: me.member_id })
252
- if ((body.on_conflict || 'rebase') === 'rebase') {
253
- await store.stageFiles({ quilt_id: q.id, commit_id: r.made.id, files: r.made.record.changes })
254
- await store.commit({ commit: { ...r.made.record, id: r.made.id, at: now() }, expect_head: sq.head, state: 'merging', actor: me.member_id, at: now(), payload: { rebased_onto: tip, reason: 'merge_conflict' }, square_patch: { base: tip } })
255
- newHead = r.made.id
256
- }
257
- await store.endMerge({ square_id: sq.id, why: 'conflict', actor: me.member_id, at: now() })
258
- const terms = await Promise.all(r.conflicts.map(async (c) => ({ ...c, sides: (await getTerm(q.id, c.term)).adds.map((s) => ({ by: s.by, commit: s.from_commit, square: s.from_square && squareShort(s.from_square) })) })))
259
- return { ok: false, refused: { code: 'conflict', detail: `conflict in ${r.conflicts.map((c) => c.path).join(', ')}` }, head: newHead, terms, warnings, square: await squareView(await store.getSquare(sq.id)) }
260
- }
261
- await seal(q.id, r.objects, { by: me.member_id })
262
- await store.stageFiles({ quilt_id: q.id, commit_id: r.made.id, files: r.made.record.changes })
263
- try {
264
- const m = await store.merge({ commit: { ...r.made.record, id: r.made.id, at: now() }, line, expected_tip: tip, expect_head: sq.head, snapshot_root: r.root, actor: me.member_id, at: now(), payload: { combined: r.combined, ...(reverts ? { reverts } : {}) } })
265
- // 03's second short transaction; if it fails, readers still see the patch as pending.
266
- await store.fillLinePaths({ quilt_id: q.id }).catch(() => {})
267
- const restacked = await restackChildren(q, sq, r.made.id, me)
268
- return { ok: true, seq: m.seq, merged: { line, patch_no: m.patch_no, commit: r.made.id, seq: m.seq }, retries, combined: r.combined, fast: r.fast, warnings, ...(restacked.length ? { restacked } : {}), square: await squareView(await store.getSquare(sq.id)) }
269
- } catch (err) {
270
- if (err.code !== 'tip_moved') throw err
271
- retries++
272
- await new Promise((resolve) => setTimeout(resolve, Math.random() * 20 * attempt))
273
- }
274
- }
275
- await store.endMerge({ square_id: sq.id, why: 'tip_moved', actor: me.member_id, at: now() })
276
- throw new QuiltError(409, 'tip_moved', `${line} kept moving; ${MAX_ATTEMPTS} attempts lost the race. Call again.`, { attempts: MAX_ATTEMPTS })
277
- } catch (err) {
278
- await store.endMerge({ square_id: sq.id, why: err.code || 'error', actor: me.member_id, at: now() }).catch(() => {})
279
- throw fromStore(err)
280
- } finally {
281
- if (release) await release()
282
- }
283
- }
284
- if (body.dry_run) return run()
285
- return once('merge', sq.id, expect, { line, square: sq.id, message: body.message || null, on_conflict: body.on_conflict || 'rebase' }, run)
286
- }
287
-
288
- // ---- the merging sweeper (07): a merge whose server died leaves its square 'merging' ----
289
-
290
- const lapsed = (sq) => sq.state === 'merging' && now() - (typeof sq.updated_at === 'number' ? sq.updated_at : Date.parse(sq.updated_at)) > LEASE_TTL_MS
291
- async function sweepOne (sq) {
292
- if (!lapsed(sq)) return false
293
- return !!(await store.endMerge({ square_id: sq.id, why: 'lease_lapsed', actor: 'system', at: now() }))
294
- }
295
- /** Returns every lapsed 'merging' square of a quilt to 'open' with a merge_refused row (why: 'lease_lapsed'). */
296
- async function sweepMerges (qid) {
297
- let n = 0
298
- for (const sq of await store.squares(qid, { state: 'merging' })) if (await sweepOne(sq)) n++
299
- return n
300
- }
301
-
302
- /** Rebase a square onto its line's tip, its open parent square's head when stacked, or `onto`: conflicts become terms, never a stop (07). */
303
- async function rebase (me, ref, sqRef, body = {}) {
304
- const q = await quiltFor(me, ref, 'write')
305
- const sq = await writable(me, q, sqRef)
306
- const expect = body.expect_head === undefined ? sq.head : body.expect_head
307
- return once('rebase', sq.id, expect, body, async () => {
308
- if (sq.state !== 'open') throw new QuiltError(409, sq.state === 'merging' ? 'square_merging' : 'square_closed', `the square is ${sq.state}`)
309
- const parent = await openParent(sq)
310
- const onto = body.onto ? await resolve(q.id, body.onto) : parent ? (parent.head || parent.base) : (await store.getLine(q.id, sq.line)).tip
311
- return rebaseSquare(q, sq, onto, me, expect, {})
312
- })
313
- }
314
-
315
- /** A stacked square's parent while it is still open or merging (06), else null. */
316
- async function openParent (sq) {
317
- if (!sq.parent_square) return null
318
- const p = await store.getSquare(sq.parent_square)
319
- return p && (p.state === 'open' || p.state === 'merging') ? p : null
320
- }
321
-
322
- async function rebaseSquare (q, sq, onto, me, expect, payload) {
323
- if (onto === sq.base) return { ok: true, noop: true, square: await squareView(sq) }
324
- if (!sq.head) {
325
- const s = await store.setSquare({ square_id: sq.id, patch: { base: onto }, op: 'rebase', actor: me.member_id, at: now() })
326
- return { ok: true, square: await squareView(s), conflicted: 0 }
327
- }
328
- const r = await rebaseCommit(q.id, sq, sq.head, onto, {})
329
- await seal(q.id, r.objects, { by: me.member_id })
330
- await store.stageFiles({ quilt_id: q.id, commit_id: r.made.id, files: r.made.record.changes })
331
- try {
332
- const row = await store.commit({ commit: { ...r.made.record, id: r.made.id, at: now() }, expect_head: expect, actor: me.member_id, at: now(), payload: { rebased_onto: onto, ...payload }, square_patch: { base: onto } })
333
- return { ok: true, seq: row.seq, result: commitView({ ...row, changes: r.made.record.changes }), conflicted: r.conflicts.length, terms: r.conflicts, combined: r.combined, square: await squareView(await store.getSquare(sq.id)) }
334
- } catch (err) { throw fromStore(err) }
335
- }
336
-
337
- /**
338
- * Restack (06, D14): when a square merges, the open squares stacked on it are rebased onto the
339
- * line's new tip, conflicts kept as terms in their heads. A child whose head moved meanwhile is
340
- * left for its holder to rebase; the answer lists what happened to each.
341
- */
342
- async function restackChildren (q, parent, tip, me) {
343
- const kids = (await store.squares(q.id, { state: 'open' })).filter((s) => s.parent_square === parent.id)
344
- const out = []
345
- for (const k of kids) {
346
- try {
347
- const r = await rebaseSquare(q, k, tip, me, k.head, { restacked: { parent_square: parent.id } })
348
- out.push({ square: squareShort(k.id), holder: k.holder, conflicted: r.conflicted || 0, ...(r.terms && r.terms.length ? { terms: r.terms } : {}) })
349
- } catch (err) {
350
- out.push({ square: squareShort(k.id), holder: k.holder, error: err.code || 'failed' })
351
- }
352
- }
353
- return out
354
- }
355
-
356
- /**
357
- * Applies one commit's change to a square as a single-parent commit (07): three-way with the
358
- * picked commit's first parent as base. `invert` is revert: base and theirs swap.
359
- */
360
- async function applyOne (q, sq, me, pickId, { invert = false, message, payload, open = false }) {
361
- const x = await getCommit(q, pickId)
362
- const parent = x.parents[0] || null
363
- const [base, theirs] = invert ? [pickId, parent] : [parent, pickId]
364
- const ours = sq.head || sq.base
365
- const sides = { ours: { commit: ours, square: sq.id, by: me.member_id }, theirs: { commit: pickId, square: x.square_id, by: x.author.member_id }, base: { commit: base } }
366
- const comb = await combine(q, base, ours, theirs, sides, { reason: 'merge', against: pickId })
367
- // Only what the picked commit changed moves; paths only ours changed stay as they are.
368
- const finals = new Map([...comb.finals].filter(([p]) => comb.theirsPaths.has(p)))
369
- const onto = await entryMap(q, ours, finals.keys())
370
- const changes = changesOnto(onto, finals)
371
- if (!changes.length) throw new QuiltError(400, 'bad_request', invert ? 'reverting that changes nothing here' : 'that change is already here')
372
- const made = makeCommit(q, sq, { parents: [ours].filter(Boolean), author: { member_id: me.member_id, kind: me.kind, name: me.name, ...(me.tool ? { tool: me.tool } : {}) }, onBehalfOf: me.on_behalf_of, message, changes })
373
- await seal(q, [...comb.blobs, ...comb.terms, made.object], { by: me.member_id })
374
- await store.stageFiles({ quilt_id: q, commit_id: made.id, files: made.record.changes })
375
- // A revert's new square is opened in the same transaction as its commit (04).
376
- const row = open
377
- ? await store.openAndCommit({ square: sq, at: now(), commit: { ...made.record, id: made.id, at: now() }, expect_head: null, actor: me.member_id, payload })
378
- : await store.commit({ commit: { ...made.record, id: made.id, at: now() }, expect_head: sq.head || null, actor: me.member_id, at: now(), payload })
379
- const conflicts = comb.conflicts.filter((c) => finals.has(c.path))
380
- return { ok: true, seq: row.seq, result: commitView({ ...row, changes: made.record.changes }), conflicted: conflicts.length, terms: conflicts, square: await squareView(await store.getSquare(sq.id)) }
381
- }
382
-
383
- async function cherryPick (me, ref, sqRef, body) {
384
- const q = await quiltFor(me, ref, 'write')
385
- const sq = await writable(me, q, sqRef)
386
- const expect = body.expect_head === undefined ? sq.head : body.expect_head
387
- return once('cherry-pick', sq.id, expect, body, async () => {
388
- if ((sq.head || null) !== (expect || null)) throw new QuiltError(409, 'head_moved', 'the square moved on since you looked', { head: sq.head })
389
- if (sq.state !== 'open') throw new QuiltError(409, 'square_closed', `the square is ${sq.state}`)
390
- const pick = await resolve(q.id, body.from)
391
- if (!pick) throw new QuiltError(404, 'not_found', `${body.from} names no commit`)
392
- const x = await getCommit(q.id, pick)
393
- try {
394
- return await applyOne(q.id, sq, me, pick, { message: body.message || `${x.message}\n\n(cherry picked from commit ${pick.slice(0, 12)})`, payload: { picked_from: pick } })
395
- } catch (err) { throw fromStore(err) }
396
- })
397
- }
398
-
399
- /**
400
- * Revert a patch (or any commit): a new square from the line tip whose one commit inverts it
401
- * (06, 07). It merges like any square.
402
- */
403
- async function revert (me, ref, body) {
404
- const q = await quiltFor(me, ref, 'write')
405
- const target = await resolve(q.id, body.rev)
406
- if (!target) throw new QuiltError(404, 'not_found', `${body.rev} names no commit`)
407
- const x = await getCommit(q.id, target)
408
- const line = body.line || 'main'
409
- const ln = await store.getLine(q.id, line)
410
- const label = x.patch ? `patch ${x.patch.line}:${x.patch.patch_no}` : `commit ${target.slice(0, 12)}`
411
- const title = `Revert ${label}: ${String(x.message).split('\n')[0]}`.slice(0, 200)
412
- const square = { id: uuidv7(now()), quilt_id: q.id, title, line, base: ln.tip, holder: me.member_id, opened_by: me.member_id, writers: [] }
413
- const sq = { ...square, head: null, state: 'open' }
414
- try {
415
- return await applyOne(q.id, sq, me, target, { open: true, invert: true, message: body.message || `${title}\n\nThis reverts ${label}.`, payload: { reverts: target, ...(x.patch ? { reverts_patch: x.patch } : {}) } })
416
- } catch (err) { throw fromStore(err) }
417
- }
418
-
419
- /**
420
- * Resolve conflict terms by choice (07's ResolveRequest): { expect_head, entries: [{ path,
421
- * choose: 'ours' | 'theirs' | 'base' | 'suggestion' | n | 'delete' | 'keep_edit' | 'keep_delete' | { blob } }] }.
422
- */
423
- async function resolveTerms (me, ref, sqRef, body) {
424
- const q = await quiltFor(me, ref, 'write')
425
- const sq = await writable(me, q, sqRef)
426
- const expect = body.expect_head === undefined ? sq.head : body.expect_head
427
- return once('resolve', sq.id, expect, body, async () => {
428
- if (!sq.head) throw new QuiltError(400, 'bad_request', 'nothing to resolve')
429
- const cur = await entryMap(q.id, sq.head, (body.entries || []).map((e) => cleanPath(e.path)))
430
- const changes = []
431
- const resolved = []
432
- const picks = []
433
- for (const e of body.entries || []) {
434
- const p = cleanPath(e.path)
435
- const at = cur.get(p)
436
- if (!at || at.mode !== 'conflict') throw new QuiltError(400, 'bad_request', `${p} is not conflicted`)
437
- const t = await getTerm(q.id, at.term)
438
- let pick
439
- const c = e.choose
440
- if (c === 'suggestion') {
441
- if (!t.suggestion) throw new QuiltError(400, 'bad_request', `${p}: this conflict has no suggestion`)
442
- const row = await store.getBlob(q.id, t.suggestion.blob)
443
- pick = { blob: t.suggestion.blob, mode: t.adds[0].mode || 'file', size: row.size }
444
- } else if (c && typeof c === 'object' && c.blob) {
445
- const row = await store.getBlob(q.id, c.blob)
446
- if (!row) throw new QuiltError(422, 'pack_unsealed', `blob ${c.blob.slice(0, 12)} is not uploaded`)
447
- pick = { blob: c.blob, mode: t.adds[0].mode || 'file', size: row.size }
448
- } else {
449
- const s = c === 'ours' || c === 'keep_edit' ? (t.adds[0].blob ? t.adds[0] : t.adds[1]) : c === 'theirs' ? t.adds[1] : c === 'base' ? t.removes[0] : Number.isInteger(c) ? t.adds[c] : null
450
- if (c === 'delete' || c === 'keep_delete' || (s && !s.blob)) pick = null
451
- else if (!s) throw new QuiltError(400, 'bad_request', `${p}: choose ours, theirs, base, suggestion, a side number, delete, keep_edit, keep_delete or { blob }`)
452
- else { const row = await store.getBlob(q.id, s.blob); pick = { blob: s.blob, mode: s.mode || 'file', size: row.size } }
453
- }
454
- changes.push(pick ? { path: p, op: 'put', ...pick } : { path: p, op: 'delete' })
455
- // A name term kept on this side wins the name: the paths it clashed with go, and the answer says so.
456
- const dropped = []
457
- if (t.kind === 'name' && pick && t.clash) {
458
- const root = await snapshot(q.id, sq.head)
459
- for (const other of t.clash) if (await reader(q.id).lookup(root, other) && !changes.some((x) => x.path === other)) { changes.push({ path: other, op: 'delete' }); dropped.push(other) }
460
- }
461
- resolved.push({ path: p, term: at.term, how: typeof c === 'object' ? 'blob' : String(c), ...(dropped.length ? { dropped } : {}) })
462
- picks.push({ path: p, term: at.term, pick, how: typeof c === 'object' ? 'blob' : String(c) })
463
- }
464
- if (!changes.length) throw new QuiltError(400, 'bad_request', 'name the paths to resolve')
465
- const head = await getCommit(q.id, sq.head)
466
- const made = makeCommit(q.id, sq, { parents: head.parents, predecessor: sq.head, author: { member_id: me.member_id, kind: me.kind, name: me.name }, onBehalfOf: me.on_behalf_of, message: head.message, changes: mergeChanges(head.changes, changes), stack: head.stack })
467
- await seal(q.id, [made.object], { by: me.member_id })
468
- await store.stageFiles({ quilt_id: q.id, commit_id: made.id, files: made.record.changes })
469
- try {
470
- const row = await store.commit({ commit: { ...made.record, id: made.id, at: now() }, expect_head: expect, actor: me.member_id, at: now(), payload: { resolved } })
471
- // Settled once, settled for good (07, D12), unless the resolver says remember: false.
472
- if (body.remember !== false) await remember(store, getTerm, q.id, picks, me, now())
473
- return { ok: true, seq: row.seq, result: commitView({ ...row, changes: made.record.changes }), conflicted: (await conflictsAt(q.id, made.id)).length, square: await squareView(await store.getSquare(sq.id)) }
474
- } catch (err) { throw fromStore(err) }
475
- })
476
- }
477
-
478
- return { mergeIntoLine, sweepMerges, rebase, cherryPick, revert, resolve: resolveTerms, combine, mergeInternals: { combine, changesOnto, entryMap, makeCommit, rebaseCommit } }
479
- }
480
-
481
- /** A commit's changes with some paths replaced (a resolution rewrites the head, D13). */
482
- function mergeChanges (old, repl) {
483
- const m = new Map(old.map((c) => [c.path, c]))
484
- for (const c of repl) m.set(c.path, c)
485
- return [...m.values()]
486
- }
@@ -1,144 +0,0 @@
1
- // Object storage for quilts (plans/quilt-source-control/03, "Object storage layout"): written
2
- // once, never overwritten, read by range. Packs live at q/<quilt_id>/p/<pack_id>, in a folder on
3
- // disk (diskBlobs) or a private Supabase Storage bucket (bucket.js). A second write of the same
4
- // key keeps the first bytes.
5
- import fs from 'node:fs'
6
- import path from 'node:path'
7
-
8
- /** Files under `dir` on this machine: <dir>/<room>/<name>. */
9
- export class DiskBlobs {
10
- constructor (dir) { this.dir = dir }
11
- file (room, name) { return path.join(this.dir, room, name) }
12
- }
13
-
14
- const packRoom = (quiltId) => `q/${quiltId}/p`
15
-
16
- /** Packs over DiskBlobs, or a bucket with uploadTarget, downloadTarget and bucket.list (bucket.js). */
17
- export class BlobstoreObjects {
18
- constructor (store, { fetch: fetchImpl = globalThis.fetch } = {}) {
19
- this.store = store
20
- this.fetch = fetchImpl
21
- }
22
-
23
- /** Writes a pack once; a pack already there is left as it is (same id, same bytes). */
24
- async putPack (quiltId, packId, bytes) {
25
- const room = packRoom(quiltId)
26
- if (this.store instanceof DiskBlobs) {
27
- const file = this.store.file(room, packId)
28
- fs.mkdirSync(path.dirname(file), { recursive: true })
29
- try { fs.writeFileSync(file, bytes, { flag: 'wx' }) } catch (err) { if (err.code !== 'EEXIST') throw err }
30
- return
31
- }
32
- const target = await this.store.uploadTarget(room, packId, bytes.length)
33
- if (target.exists) return
34
- const res = await this.fetch(target.url, { method: target.method || 'PUT', headers: { 'content-type': 'application/octet-stream', 'x-upsert': 'false' }, body: bytes })
35
- if (res.ok) { await res.body?.cancel().catch(() => {}); return }
36
- // The pack is there already (Supabase says 409, or 400 "Duplicate"), which for
37
- // content-addressed bytes is success.
38
- const text = await res.text().catch(() => '')
39
- if (res.status === 409 || /duplicate|already exists/i.test(text)) return
40
- throw new Error(`storage: pack upload answered ${res.status}`)
41
- }
42
-
43
- /** `len` bytes of a pack from `offset` (the whole pack when no range is given). */
44
- async readPack (quiltId, packId, offset, len) {
45
- const room = packRoom(quiltId)
46
- if (this.store instanceof DiskBlobs) {
47
- const file = this.store.file(room, packId)
48
- if (offset == null) return fs.readFileSync(file)
49
- const fd = fs.openSync(file, 'r')
50
- try {
51
- const buf = Buffer.alloc(len)
52
- const n = fs.readSync(fd, buf, 0, len, offset)
53
- if (n !== len) throw new Error(`storage: pack ${packId} is shorter than its index says`)
54
- return buf
55
- } finally { fs.closeSync(fd) }
56
- }
57
- const { url } = await this.store.downloadTarget(room, packId)
58
- const headers = offset == null ? {} : { range: `bytes=${offset}-${offset + len - 1}` }
59
- const res = await this.fetch(url, { headers })
60
- if (!res.ok) throw new Error(`storage: pack read answered ${res.status}`)
61
- const buf = Buffer.from(await res.arrayBuffer())
62
- // A store that ignores Range sends the whole pack.
63
- return offset != null && res.status === 200 && buf.length !== len ? buf.subarray(offset, offset + len) : buf
64
- }
65
-
66
- // Other written-once objects of a quilt (03's layout: `w` WAL segments, `j` journal groups):
67
- // q/<quilt_id>/<kind>/<name>. Same write-once rule as packs.
68
- async putFile (quiltId, kind, name, bytes) {
69
- const room = `q/${quiltId}/${kind}`
70
- if (this.store instanceof DiskBlobs) {
71
- const file = this.store.file(room, name)
72
- fs.mkdirSync(path.dirname(file), { recursive: true })
73
- try { fs.writeFileSync(file, bytes, { flag: 'wx' }) } catch (err) { if (err.code !== 'EEXIST') throw err }
74
- return
75
- }
76
- const target = await this.store.uploadTarget(room, name, bytes.length)
77
- if (target.exists) return
78
- const res = await this.fetch(target.url, { method: target.method || 'PUT', headers: { 'content-type': 'application/octet-stream', 'x-upsert': 'false' }, body: bytes })
79
- if (res.ok) { await res.body?.cancel().catch(() => {}); return }
80
- const text = await res.text().catch(() => '')
81
- if (res.status === 409 || /duplicate|already exists/i.test(text)) return
82
- throw new Error(`storage: ${kind}/${name} upload answered ${res.status}`)
83
- }
84
-
85
- async readFile (quiltId, kind, name) {
86
- const room = `q/${quiltId}/${kind}`
87
- if (this.store instanceof DiskBlobs) return fs.readFileSync(this.store.file(room, name))
88
- const { url } = await this.store.downloadTarget(room, name)
89
- const res = await this.fetch(url)
90
- if (!res.ok) throw new Error(`storage: ${kind}/${name} read answered ${res.status}`)
91
- return Buffer.from(await res.arrayBuffer())
92
- }
93
-
94
- /** The names under q/<quilt_id>/<kind>, sorted. */
95
- async listFiles (quiltId, kind) {
96
- const room = `q/${quiltId}/${kind}`
97
- if (this.store instanceof DiskBlobs) {
98
- try { return fs.readdirSync(path.join(this.store.dir, room)).sort() } catch (err) { if (err.code === 'ENOENT') return []; throw err }
99
- }
100
- const names = []
101
- for (let offset = 0; ; offset += 1000) {
102
- const { data, error } = await this.store.bucket.list(room, { limit: 1000, offset, sortBy: { column: 'name', order: 'asc' } })
103
- if (error) throw new Error(`storage: ${error.message}`)
104
- names.push(...data.map((f) => f.name))
105
- if (data.length < 1000) return names.sort()
106
- }
107
- }
108
- }
109
-
110
- /** Packs in memory, for tests. */
111
- export class MemoryObjects {
112
- constructor () { this.packs = new Map(); this.puts = 0 }
113
- async putPack (quiltId, packId, bytes) {
114
- const key = `${quiltId}/${packId}`
115
- this.puts++
116
- if (!this.packs.has(key)) this.packs.set(key, Buffer.from(bytes))
117
- }
118
-
119
- async readPack (quiltId, packId, offset, len) {
120
- const b = this.packs.get(`${quiltId}/${packId}`)
121
- if (!b) throw new Error(`storage: no pack ${packId}`)
122
- return offset == null ? Buffer.from(b) : Buffer.from(b.subarray(offset, offset + len))
123
- }
124
-
125
- async putFile (quiltId, kind, name, bytes) {
126
- const key = `${quiltId}/${kind}/${name}`
127
- this.puts++
128
- if (!this.packs.has(key)) this.packs.set(key, Buffer.from(bytes))
129
- }
130
-
131
- async readFile (quiltId, kind, name) {
132
- const b = this.packs.get(`${quiltId}/${kind}/${name}`)
133
- if (!b) throw new Error(`storage: no ${kind}/${name}`)
134
- return Buffer.from(b)
135
- }
136
-
137
- async listFiles (quiltId, kind) {
138
- const pre = `${quiltId}/${kind}/`
139
- return [...this.packs.keys()].filter((key) => key.startsWith(pre)).map((key) => key.slice(pre.length)).sort()
140
- }
141
- }
142
-
143
- /** A disk-backed object store in `dir`. */
144
- export const diskObjects = (dir) => new BlobstoreObjects(new DiskBlobs(dir))