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
@@ -0,0 +1,466 @@
1
+ // Quilt's side of Seam: the room's switch to layout 3 (Seam's plans/quilt-source-control/16, "How a
2
+ // session becomes a quilt"). After the session became a quilt (session-migrate.js), each branch
3
+ // document is copied into its square's live document on the relay (`quilt/<square id>`,
4
+ // source-live/live-square.js), whose shape is an overlay: only the paths that differ from the
5
+ // square's head. The default branch's difference from `main` goes to an adoption square. The copy
6
+ // is checked against the branch document before `meta.layout = 3` is written; the branch
7
+ // documents themselves stay where they are, read-only, so going back to layout 2 loses nothing.
8
+ // Seam (src/quilts/) never imports this.
9
+ import crypto from 'node:crypto'
10
+ import path from 'node:path'
11
+ import * as Y from 'yjs'
12
+ import { blobId } from '@heyquilt/seam/lib/quilts/records.js'
13
+ import { liveMaps, startHead, entryOf, entryId } from './source-live/live-square.js'
14
+ import { makeIgnore, scopeIgnore, isIgnored, submodulePathsIn, IGNORE_FILES, MAX_TEXT_BYTES, MAX_BINARY_BYTES } from './pathrules.js'
15
+ import { looksBinary, sha1 } from './fsutil.js'
16
+ import { applyTextDiff } from './textdiff.js'
17
+ import { sameEditClient, asClient } from './same-edit.js'
18
+ import { removePath } from './branch-view.js'
19
+ import { mergeAuto } from './merge3.js'
20
+
21
+ export const LAYOUT_QUILT = 3
22
+
23
+ // A branch file as the switch holds it: its text, or { blob, size?, bytes? } (branchFiles).
24
+ const idOf = (v) => v && typeof v === 'object' ? v.blob : blobId(Buffer.from(String(v), 'utf8'))
25
+ const has = (o, p) => Object.prototype.hasOwnProperty.call(o || {}, p)
26
+
27
+ /** A Seam tree answer ({ entries: [{ path, blob, mode }] }) as path -> blob id (conflict terms by term id). */
28
+ export function treeIds (tree) {
29
+ const out = {}
30
+ for (const e of (tree && tree.entries) || []) out[e.path] = e.blob || e.term || null
31
+ return out
32
+ }
33
+
34
+ /** The executable files and links of a Seam tree answer: path -> 'exec' | 'symlink'. */
35
+ export function treeModes (tree) {
36
+ const out = {}
37
+ for (const e of (tree && tree.entries) || []) if (e.mode === 'exec' || e.mode === 'symlink') out[e.path] = e.mode
38
+ return out
39
+ }
40
+
41
+ /** What the session never syncs (fsutil.js loadIgnore), as the ignore files among `files` (path -> text) say. */
42
+ export function docIgnore (files) {
43
+ const ig = makeIgnore()
44
+ // A submodule's folder is its own git's (fsutil.js loadIgnore).
45
+ if (typeof (files || {})['.gitmodules'] === 'string') for (const p of submodulePathsIn(files['.gitmodules'])) ig.add(`/${p}`)
46
+ for (const [p, t] of Object.entries(files || {})) {
47
+ if (typeof t !== 'string' || !IGNORE_FILES.includes(path.posix.basename(p))) continue
48
+ const dir = path.posix.dirname(p)
49
+ ig.add(scopeIgnore(t, dir === '.' ? '' : dir))
50
+ }
51
+ return (p) => isIgnored(ig, p)
52
+ }
53
+
54
+ /**
55
+ * A branch document's files as the switch compares them with a square's head (`tree`, Seam's
56
+ * answer): path -> text, or { blob, size, bytes } for a small binary the document holds, or
57
+ * { blob } (the head's) for a path the document leaves as the head has it. Those are what the
58
+ * session never syncs (links, submodules, ignored files, files too large for it), a large file in
59
+ * the session's storage (the relay can't read its bytes), and in a diff document every file it
60
+ * has no entry for (it follows git). Any other head path the document lacks is a deletion, but
61
+ * with `unheld` (a square that starts at main's tip for want of the branch's own commit) only one
62
+ * it held and let go of: main's work after the branch left it is not the branch's to delete.
63
+ */
64
+ export function branchFiles (doc, tree, { diff = false, unheld = false } = {}) {
65
+ const head = treeIds(tree)
66
+ const entries = new Map(((tree && tree.entries) || []).map((e) => [e.path, e]))
67
+ const out = {}
68
+ const blobs = doc.getMap('blobs')
69
+ for (const [p, t] of doc.getMap('files')) out[p] = t.toString()
70
+ for (const [p, b] of blobs) {
71
+ if (has(out, p) || !b) continue
72
+ if (typeof b.data === 'string') { const bytes = Buffer.from(b.data, 'base64'); out[p] = { blob: blobId(bytes), size: bytes.length, bytes } } else if (head[p]) out[p] = { blob: head[p] }
73
+ }
74
+ const ignored = docIgnore(out)
75
+ // An untracked file the branch's ignore rules or Quilt's built-ins leave out (an agent's scratch
76
+ // folder, logs, supabase/.temp, .claude/settings.local.json) is local to the folder that has it, never
77
+ // a square's (Seam's 05: ignore rules gate untracked paths; 2026-11-01: 31 of them went into one).
78
+ for (const p of Object.keys(out)) if (!head[p] && ignored(p)) delete out[p]
79
+ // Nothing inside a submodule (a gitlink of the head, or a folder .gitmodules names): a document
80
+ // that took its files before they were left out (fsutil.js loadIgnore) never puts them in a square.
81
+ const subs = [...[...entries.values()].filter((e) => e.mode === 'gitlink').map((e) => e.path), ...(typeof out['.gitmodules'] === 'string' ? submodulePathsIn(out['.gitmodules']) : [])]
82
+ for (const p of Object.keys(out)) if (subs.some((d) => p.startsWith(`${d}/`))) delete out[p]
83
+ const gone = doc.getMap('gone')
84
+ const unsynced = (p) => {
85
+ const e = entries.get(p) || {}
86
+ return e.mode === 'symlink' || e.mode === 'gitlink' || (Number(e.size) || 0) > MAX_TEXT_BYTES || ignored(p)
87
+ }
88
+ const held = (p) => doc.getMap('files')._map.has(p) || blobs._map.has(p)
89
+ for (const p of Object.keys(head)) if (!has(out, p) && head[p] && (diff ? !gone.has(p) : unsynced(p) || (unheld && !held(p)))) out[p] = { blob: head[p] }
90
+ return out
91
+ }
92
+
93
+ /**
94
+ * The session's people (`members`: [{ key, role }]) become writers of a square its holder (or an
95
+ * admin) acts on through `client`, so their AIs, working for them, write there too: every person
96
+ * but the holder and viewers. What Seam refused: [{ member, why }] (one not in the quilt, a full square).
97
+ */
98
+ export async function shareWithPeople (client, quilt, square, members) {
99
+ const s = await client.square(quilt, square)
100
+ const me = s && s.holder
101
+ const refused = []
102
+ for (const m of members) {
103
+ const id = m && m.key
104
+ if (!id || !String(id).startsWith('person:') || id === me || (s.writers || []).includes(id) || m.role === 'viewer') continue
105
+ await client.share(quilt, square, { member: id }).catch((err) => refused.push({ member: id, why: String(err.message || err).slice(0, 200) }))
106
+ }
107
+ return refused
108
+ }
109
+
110
+ /** git's id of a file's bytes (what `git hash-object` says), for finding them in git's history. */
111
+ export const gitBlobId = (buf) => crypto.createHash('sha1').update(`blob ${buf.length}\0`).update(buf).digest('hex')
112
+
113
+ /** The paths of `files` (branchFiles) that differ from `head`, with git's id of the branch's bytes: Map path -> git blob id. */
114
+ export function changedGitIds (files, head) {
115
+ const out = new Map()
116
+ for (const [p, v] of Object.entries(files || {})) {
117
+ if (head[p] && head[p] === idOf(v)) continue
118
+ const buf = typeof v === 'string' ? Buffer.from(v, 'utf8') : v && v.bytes ? v.bytes : null
119
+ if (buf) out.set(p, gitBlobId(buf))
120
+ }
121
+ return out
122
+ }
123
+
124
+ /**
125
+ * `files` without the branch's older copies of main's files: a path whose bytes the branch holds are
126
+ * one of main's own earlier versions of it (`history`: path -> the git blob ids main's history gave
127
+ * it), main's tip having moved on, is the head's (gone, when the head has no such file). For a square
128
+ * that starts at main's tip for want of the branch's commit, where a full document names every
129
+ * file. A branch that put a file back as main once had it on purpose loses that: the head's stands.
130
+ */
131
+ export function dropOldCopies (files, head, history) {
132
+ const out = { ...files }
133
+ for (const [p, id] of changedGitIds(files, head)) {
134
+ if (!(history.get(p) || new Set()).has(id)) continue
135
+ if (head[p]) out[p] = { blob: head[p] }; else delete out[p]
136
+ }
137
+ return out
138
+ }
139
+
140
+ /** The small binaries in `files` that differ from `head`: what Seam must hold before a live document names them. */
141
+ export function binariesToUpload (files, head = {}) {
142
+ const out = []
143
+ for (const [p, v] of Object.entries(files || {})) if (v && typeof v === 'object' && v.bytes && head[p] !== v.blob) out.push({ kind: 'blob', id: v.blob, bytes: v.bytes })
144
+ return out
145
+ }
146
+
147
+ /** What turns `head` (path -> id) into `files` (path -> text): { puts: { path: text }, gone: [path] }. */
148
+ export function overlayFor (files, head) {
149
+ const puts = {}
150
+ for (const [p, t] of Object.entries(files || {})) if (head[p] !== idOf(t)) puts[p] = t
151
+ const gone = Object.keys(head || {}).filter((p) => !has(files, p)).sort()
152
+ return { puts, gone }
153
+ }
154
+
155
+ /** Writes an overlay into an empty live document, with `head` as its first head. */
156
+ export function writeOverlay (doc, { puts, gone }, head, by = '') {
157
+ const m = liveMaps(doc)
158
+ doc.transact(() => {
159
+ for (const [p, t] of Object.entries(puts)) {
160
+ if (t && typeof t === 'object') { m.blobs.set(p, { blob: t.blob, size: t.size || 0, mode: 'file' }); continue }
161
+ const y = new Y.Text(); y.insert(0, t); m.files.set(p, y)
162
+ }
163
+ for (const p of gone) m.gone.set(p, { by, ts: Date.now() })
164
+ }, 'relay-quilt')
165
+ if (head) startHead(doc, head, by)
166
+ }
167
+
168
+ /**
169
+ * The post-copy check (16, "Dry run and check"): the live document over its head holds exactly
170
+ * the branch document's files, by id. Returns null, or the first difference in words.
171
+ */
172
+ export function checkOverlay (doc, files, head) {
173
+ const want = {}
174
+ for (const [p, t] of Object.entries(files || {})) want[p] = idOf(t)
175
+ const have = { ...head }
176
+ const m = liveMaps(doc)
177
+ for (const p of new Set([...m.files.keys(), ...m.blobs.keys(), ...m.gone.keys()])) {
178
+ const e = entryOf(doc, p)
179
+ if (!e) continue
180
+ if (e.op === 'delete') delete have[p]; else have[p] = entryId(e)
181
+ }
182
+ for (const p of new Set([...Object.keys(want), ...Object.keys(have)])) {
183
+ if (want[p] !== have[p]) return want[p] == null ? `${p} is in the square but not in the branch document` : have[p] == null ? `${p} is in the branch document but not in the square` : `${p} differs between the branch document and the square`
184
+ }
185
+ return null
186
+ }
187
+
188
+ // Branch-document maps that are bookkeeping for two authorities (16's table, "Dropped"): emptied
189
+ // in a square's live copy. The kept branch documents still have them for a rollback.
190
+ export const DROPPED_DOC_MAPS = ['changes', 'fromGithub', 'published']
191
+
192
+ /**
193
+ * The live document as a copy of the branch document (16: "The Y.Doc is copied ..., so edit
194
+ * history inside it survives"), then made an overlay over `head` (path -> blob id): every text
195
+ * equal to head's is marked clean (the first commit drops it), a head path the branch lacks is
196
+ * marked gone, the branch's large-file entries and git bookkeeping leave, and `skip` paths (held
197
+ * by conflict terms in Seam) are taken out so head's term shows. `files` is the branch's files
198
+ * (branchFiles), as the check reads them; `modes` the head's (treeModes), so a changed executable
199
+ * stays one.
200
+ */
201
+ export function copyIntoLive (doc, branchDoc, { files, head = {}, modes = {}, commit = null, by = '', skip = new Set() }) {
202
+ Y.applyUpdate(doc, Y.encodeStateAsUpdate(branchDoc), 'relay-quilt')
203
+ const m = liveMaps(doc)
204
+ doc.transact(() => {
205
+ // The branch's own binary entries leave (its large files, and small ones as base64); so does a
206
+ // binary this set before that the branch no longer changes (copied again), set again just below.
207
+ const changedBinary = (p) => { const v = files[p]; return !!(v && typeof v === 'object' && v.bytes && !skip.has(p) && head[p] !== v.blob) }
208
+ for (const p of [...m.blobs.keys()]) { const b = m.blobs.get(p); if (!b || !b.blob || !changedBinary(p)) m.blobs.delete(p) }
209
+ for (const name of DROPPED_DOC_MAPS) { const x = doc.getMap(name); for (const k of [...x.keys()]) x.delete(k) }
210
+ // A small binary the branch changed (its bytes are in Seam already: binariesToUpload).
211
+ for (const [p, v] of Object.entries(files)) if (changedBinary(p) && (m.blobs.get(p) || {}).blob !== v.blob) m.blobs.set(p, { blob: v.blob, size: v.size, mode: modes[p] === 'exec' ? 'exec' : 'file' })
212
+ // The session marks an executable file 'x'; a square says 'exec', and a text the branch changed keeps the head's mode.
213
+ for (const [p, v] of [...m.modes.entries()]) { if (v === 'x') m.modes.set(p, 'exec'); else if (v !== 'exec' && v !== 'symlink') m.modes.delete(p) }
214
+ for (const p of [...m.files.keys()]) {
215
+ if (skip.has(p)) { m.files.delete(p); continue }
216
+ // Not in the switch's files nor the head: a text the switch left out (dropOldCopies: main deleted it since), not work.
217
+ if (!has(files, p)) { if (!head[p]) m.files.delete(p); continue }
218
+ if (head[p] === idOf(files[p])) m.clean.set(p, { at: commit }); else {
219
+ if (m.clean.has(p)) m.clean.delete(p)
220
+ if (!m.modes.has(p) && modes[p] === 'exec') m.modes.set(p, 'exec')
221
+ }
222
+ }
223
+ for (const p of Object.keys(head)) if (!skip.has(p) && !has(files, p)) m.gone.set(p, { by, ts: Date.now() })
224
+ // Copied again (edits that came while the move waited): a path the branch has again is no longer gone;
225
+ // nor is one the head does not have (main deleted it already: as carryLive drops it after a merge).
226
+ for (const p of [...m.gone.keys()]) if ((has(files, p) && !skip.has(p)) || !head[p]) m.gone.delete(p)
227
+ }, 'relay-quilt')
228
+ if (commit) startHead(doc, commit, by)
229
+ // The head the copy is an overlay of: a write that still reaches the branch document is forwarded
230
+ // into the square only while the square is on it (server.js forwardToSquare, copiedHead).
231
+ if (commit) doc.transact(() => liveMaps(doc).live.set('copied', { commit, ts: Date.now() }), 'relay-quilt')
232
+ }
233
+
234
+ /**
235
+ * The head a square's live document was copied over from its branch document (copyIntoLive), or null.
236
+ * While the square is still on it, the branch document and the square agree on every file the folder
237
+ * did not change, so a write to the branch document is the folder's own change on the square's head too.
238
+ */
239
+ export const copiedHead = (doc) => (doc.getMap('live').get('copied') || {}).commit || null
240
+
241
+ /**
242
+ * After a branch's square merged (a merged square never changes again), its next square's live
243
+ * document carries on from the merged one: a copy of it (edit history kept) made an overlay over
244
+ * `head` (path -> blob id, the merged commit's tree, `commit` its id). What the merge already
245
+ * has leaves, as `seal` drops it, and so do clean marks (equal to the old head, not the new one,
246
+ * so keeping them would undo whatever else merged); work typed after the last commit stays.
247
+ */
248
+ export function carryLive (doc, fromDoc, { head = {}, commit, by = '' }) {
249
+ Y.applyUpdate(doc, Y.encodeStateAsUpdate(fromDoc), 'relay-quilt')
250
+ const m = liveMaps(doc)
251
+ doc.transact(() => {
252
+ const drop = (p) => { for (const k of ['files', 'blobs', 'gone', 'modes', 'clean']) m[k].delete(p) }
253
+ for (const p of [...m.clean.keys()]) drop(p)
254
+ for (const p of new Set([...m.files.keys(), ...m.blobs.keys(), ...m.gone.keys()])) {
255
+ const e = entryOf(doc, p)
256
+ if (!e || (e.op === 'delete' ? !head[p] : head[p] === entryId(e))) drop(p)
257
+ }
258
+ m.live.set('head', { commit, by, ts: Date.now() })
259
+ }, 'relay-quilt')
260
+ }
261
+
262
+ // Room meta that is bookkeeping for git as an authority (16's table, "Dropped"). At layout 3 it
263
+ // moves into meta.quilt.kept, and back on a rollback, so git-mode sync resumes as it was.
264
+ export const GIT_META = ['sync', 'upstreams', 'remoteBranches', 'viewBranches', 'ciPending', 'pullTold', 'githubToken', 'githubSync', 'largeToRelay', 'recreateDeleted']
265
+ const isGitMeta = (k) => GIT_META.includes(k) || k.startsWith('agentCommits')
266
+
267
+ /** Takes git's bookkeeping out of `meta`: what was there, to put back with restoreGitMeta. */
268
+ export function takeGitMeta (meta) {
269
+ const kept = {}
270
+ for (const k of Object.keys(meta)) if (isGitMeta(k)) { kept[k] = meta[k]; delete meta[k] }
271
+ return kept
272
+ }
273
+
274
+ export function restoreGitMeta (meta, kept) {
275
+ for (const [k, v] of Object.entries(kept || {})) if (isGitMeta(k)) meta[k] = v
276
+ }
277
+
278
+ /**
279
+ * Where a branch document's square starts in a quilt imported from the session's git remote: the
280
+ * imported commit of the ref's tip (`git:<sha>`, Seam resolves it through its translation table),
281
+ * a detached document's own commit (`@<sha>`), or main's tip when nothing names one (null).
282
+ */
283
+ export function squareBase (key, meta) {
284
+ const at = /^@([0-9a-f]{7,64})$/.exec(String(key))
285
+ if (at) return `git:${at[1]}`
286
+ const up = meta && meta.upstreams && meta.upstreams[key]
287
+ if (up && /^[0-9a-f]{40,64}$/.test(String(up.sha || ''))) return `git:${up.sha}`
288
+ const b = meta && meta.branches && meta.branches[key]
289
+ if (b && /^[0-9a-f]{40,64}$/.test(String(b.base || ''))) return `git:${b.base}`
290
+ return null
291
+ }
292
+
293
+ /**
294
+ * Applies a member's Yjs update to a square's live document as the relay (`origin`), where the
295
+ * guard (server.js checkChange) never looks: `may(path)` says whether they may change a path, and an
296
+ * update that touches one they may not is undone whole (its structs stay, deleted, so the member's
297
+ * later updates still fit the document). { touched: [path], refused: [path] }.
298
+ */
299
+ export function applyChecked (doc, update, may = () => true, origin = 'relay-forward') {
300
+ const m = liveMaps(doc)
301
+ const touched = new Set()
302
+ const seen = (tr) => {
303
+ if (tr.origin !== origin) return
304
+ for (const [type, keys] of tr.changed) {
305
+ if (type === m.files || type === m.blobs || type === m.gone || type === m.modes) { for (const k of keys) if (k != null) touched.add(k) } else if (type instanceof Y.Text && type.parent === m.files && type._item) touched.add(type._item.parentSub)
306
+ }
307
+ }
308
+ const um = new Y.UndoManager([m.files, m.blobs, m.gone, m.modes, m.clean], { trackedOrigins: new Set([origin]), captureTimeout: 0 })
309
+ doc.on('afterTransaction', seen)
310
+ try { Y.applyUpdate(doc, update, origin) } finally { doc.off('afterTransaction', seen) }
311
+ const refused = [...touched].filter((p) => !may(p))
312
+ if (refused.length) um.undo()
313
+ um.destroy()
314
+ return { touched: [...touched], refused }
315
+ }
316
+
317
+ /**
318
+ * A write that reached a branch's kept document at layout 3 from an app not yet following the
319
+ * branch's square (the moment between the switch and the app hearing it): applied to the square's
320
+ * live document instead, which is a copy of the branch document, so the update fits it, as far as
321
+ * its member may change those files (`may`, applyChecked). Each path it put loses a clean or gone
322
+ * mark (it is the member's edit now). Returns the paths it deleted (gone only when the square's head
323
+ * has them: the caller looks), binaries it can't keep (the branch document's bytes are not in Seam;
324
+ * the folder's block takes them in when it follows), and the paths it refused.
325
+ */
326
+ export function forwardIntoLive (doc, update, { may, origin = 'relay-forward' } = {}) {
327
+ const m = liveMaps(doc)
328
+ const { touched, refused } = applyChecked(doc, update, may, origin)
329
+ if (refused.length) return { deleted: [], binaries: [], refused }
330
+ const deleted = []; const binaries = []
331
+ doc.transact(() => {
332
+ for (const p of touched) {
333
+ const b = m.blobs.get(p)
334
+ if (b && !b.blob) { m.blobs.delete(p); binaries.push(p); continue }
335
+ if (m.modes.get(p) === 'x') m.modes.set(p, 'exec') // the session marks an executable 'x'; a square says 'exec' (copyIntoLive)
336
+ if (m.files.has(p) || b) { m.clean.delete(p); m.gone.delete(p) } else deleted.push(p)
337
+ }
338
+ }, 'relay-quilt')
339
+ return { deleted, binaries, refused: [] }
340
+ }
341
+
342
+ /**
343
+ * Whether a Yjs update would change `doc`: content it lacks, or a deletion of what it still has.
344
+ * A read-only branch document's member is told only about a write that was really refused (the
345
+ * empty answer every app sends when it joins is no write).
346
+ */
347
+ export function bringsNew (doc, update) {
348
+ const sv = Y.decodeStateVector(Y.encodeStateVector(doc))
349
+ const { structs, ds } = Y.decodeUpdate(update)
350
+ // Each struct by its own clock: Yjs's encodeStateVectorFromUpdate leaves out a client whose structs
351
+ // do not start at clock 0, which is every member typing on into a file the relay already has.
352
+ for (const s of structs) if (!(s instanceof Y.Skip) && s.id.clock + s.length > (sv.get(s.id.client) || 0)) return true
353
+ if (!ds || !ds.clients.size) return false
354
+ const have = Y.createDeleteSetFromStructStore(doc.store)
355
+ for (const [client, items] of ds.clients) {
356
+ for (const it of items) {
357
+ if (it.clock >= (sv.get(client) || 0)) continue
358
+ for (const c of [it.clock, Math.min(it.clock + it.len, sv.get(client) || 0) - 1]) if (!Y.isDeleted(have, Y.createID(client, c))) return true
359
+ }
360
+ }
361
+ return false
362
+ }
363
+
364
+ /**
365
+ * Going back (quiltUnswitch) with a square no folder follows any more: what its branch document
366
+ * lacks of it. `square` is the square's files (path -> { blob, mode }), `cur` the branch's
367
+ * (branchFiles over the same tree, so what the session never syncs reads as the square has it).
368
+ * { put: [{ path, blob, mode }], gone: [path], skip: [{ path, why }] }: links and submodules are
369
+ * never synced, nor what the branch's ignore files leave out.
370
+ */
371
+ export function foldBack (square, cur) {
372
+ const ignored = docIgnore(cur)
373
+ const out = { put: [], gone: [], skip: [] }
374
+ for (const p of [...new Set([...Object.keys(square), ...Object.keys(cur)])].sort()) {
375
+ const want = square[p]
376
+ const have = has(cur, p) ? idOf(cur[p]) : null
377
+ if ((want ? want.blob : null) === have) continue
378
+ if (want && want.term) { out.skip.push({ path: p, why: 'a conflict the square had not settled' }); continue }
379
+ if (want && (want.mode === 'symlink' || want.mode === 'gitlink')) { out.skip.push({ path: p, why: `a ${want.mode === 'symlink' ? 'link' : 'submodule'}, which the session never syncs` }); continue }
380
+ if (ignored(p)) continue
381
+ if (want) out.put.push({ path: p, blob: want.blob, mode: want.mode || 'file' }); else out.gone.push(p)
382
+ }
383
+ return out
384
+ }
385
+
386
+ /**
387
+ * Writes what foldBack found into branch entry `e` (branchdocs.js), with `bytes` (blob id -> Buffer).
388
+ * A text is written as the folders write the square's work when they leave it (Session.leaveSquare,
389
+ * same-edit.js): under an id taken from the path, before and after, so a folder that brings the same
390
+ * work back later makes the very same edit and the text lands once. Returns the paths written and
391
+ * the ones it could not take ({ path, why }).
392
+ */
393
+ export function writeFold (e, fold, bytes, by = '') {
394
+ const done = []; const left = []
395
+ e.doc.transact(() => {
396
+ for (const x of fold.put) {
397
+ const buf = bytes[x.blob]
398
+ if (!buf) { left.push({ path: x.path, why: 'Seam did not give its bytes' }); continue }
399
+ if (looksBinary(buf)) {
400
+ if (buf.length > MAX_BINARY_BYTES) { left.push({ path: x.path, why: `${Math.round(buf.length / 1048576)} MB, larger than the session keeps in a branch` }); continue }
401
+ e.files.delete(x.path)
402
+ e.blobs.set(x.path, { hash: sha1(buf), data: buf.toString('base64') })
403
+ } else {
404
+ if (buf.length > MAX_TEXT_BYTES) { left.push({ path: x.path, why: `${Math.round(buf.length / 1048576)} MB of text, larger than the session syncs` }); continue }
405
+ const text = buf.toString('utf8')
406
+ const before = e.files.has(x.path) ? e.files.get(x.path).toString() : null
407
+ e.blobs.delete(x.path)
408
+ asClient(e.doc, sameEditClient('square', x.path, before, text), () => {
409
+ let y = e.files.get(x.path)
410
+ if (!y) { y = new Y.Text(); e.files.set(x.path, y) }
411
+ applyTextDiff(y, text)
412
+ })
413
+ }
414
+ if (x.mode === 'exec') { if (e.modes.get(x.path) !== 'x') e.modes.set(x.path, 'x') } else if (e.modes.has(x.path)) e.modes.delete(x.path)
415
+ done.push(x.path)
416
+ }
417
+ for (const p of fold.gone) { removePath(e, p, { by }); done.push(p) }
418
+ }, 'relay-quilt')
419
+ return { done, left }
420
+ }
421
+
422
+ /** How far apart two texts are, line by line (lines one has that the other lacks, counted with repeats). */
423
+ export function lineDistance (a, b) {
424
+ const count = new Map()
425
+ for (const l of String(a).split('\n')) count.set(l, (count.get(l) || 0) + 1)
426
+ let n = 0
427
+ for (const l of String(b).split('\n')) { const c = count.get(l) || 0; if (c) count.set(l, c - 1); else n++ }
428
+ for (const c of count.values()) n += c
429
+ return n
430
+ }
431
+
432
+ /**
433
+ * A boxed file at the move (a member's unfinished work the session held while every other folder kept
434
+ * git's copy): the branch document holds the box holder's whole file, on the main they had when they
435
+ * began, which may be far behind the square's head. Carried as it is, it would undo main's newer
436
+ * changes to the file in the square, and in every folder that follows it. `versions` are main's
437
+ * earlier versions of the file (from the head's history): the nearest to the box's copy is what it
438
+ * started from, and only the box's change from there goes onto `head` (the head's text). Returns the
439
+ * text for the square, or null when the box's copy already is the head plus its change (or nothing
440
+ * in the history is near enough to tell: then the copy stands, as before).
441
+ */
442
+ export function boxOntoHead (box, head, versions) {
443
+ let base = null; let best = Infinity
444
+ for (const v of versions) { if (typeof v !== 'string') continue; const d = lineDistance(box, v); if (d < best) { best = d; base = v } }
445
+ if (base === null || base === head) return null
446
+ // Not the box's starting point unless the box's copy is nearer to it than to the head.
447
+ if (best >= lineDistance(box, head)) return null
448
+ return mergeAuto(base, box, head, { oursNewer: true }).text
449
+ }
450
+
451
+ /**
452
+ * Puts `texts` (path -> the square's text, boxOntoHead) into a square's live document after the
453
+ * move's copy (copyIntoLive): a path whose text is the head's is clean, any other is the square's work.
454
+ */
455
+ export function writeIntoLive (doc, texts, head = {}, commit = null) {
456
+ const m = liveMaps(doc)
457
+ doc.transact(() => {
458
+ for (const [p, t] of Object.entries(texts)) {
459
+ m.gone.delete(p); m.blobs.delete(p)
460
+ let y = m.files.get(p)
461
+ if (!y) { y = new Y.Text(); m.files.set(p, y) }
462
+ applyTextDiff(y, t)
463
+ if (head[p] === idOf(t)) m.clean.set(p, { at: commit }); else m.clean.delete(p)
464
+ }
465
+ }, 'relay-quilt')
466
+ }
@@ -0,0 +1,123 @@
1
+ // Quilt's side of Seam: a session with no git remote becomes a quilt
2
+ // (Seam's plans/quilt-source-control/16, "How a session becomes a quilt", "Without history").
3
+ // It calls Seam through a client with the quiltClient shape (src/quilts/client.js), acting as
4
+ // the session's owner, who becomes the quilt's admin. Seam (src/quilts/) never imports this.
5
+ //
6
+ // 1. a quilt named after the room (slug from the room, `-2`.. when taken);
7
+ // 2. patch 1 on main: the default branch document's files, sealed in a square of the owner's
8
+ // and merged, message "Session <room> as of <date>";
9
+ // 3. one square per other branch document, based at patch 1, whose live changes are its
10
+ // difference from patch 1 (so it shows as uncommitted work).
11
+ //
12
+ // 4. each open merge record (state open or editing) as a conflict term in its square's first
13
+ // commit (16's merges row): `ours` the opener's text, on the migrated square; `theirs` the
14
+ // session's text, naming patch 1's square and commit; the record's base as the remove. A
15
+ // default branch's open merges get a square of their own ('(default)'), as main is patch 1.
16
+ // Binary records, and records whose text stayed on the opener's disk, have nothing to put
17
+ // in a term: they are listed in `merges_left`.
18
+ //
19
+ // Small binaries (kept in the branch document as base64) go in with the texts: patch 1 carries
20
+ // the default branch's, a branch's square its own. Files kept in storage (large files) have no
21
+ // bytes on the relay; they are listed in `left_out` and stay in the session's storage.
22
+
23
+ export const slugFor = (room) => String(room || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 56) || 'session'
24
+
25
+ const b64 = (text) => Buffer.from(String(text)).toString('base64')
26
+ const put = (path, text) => ({ path, op: 'put', content: b64(text) })
27
+
28
+ /** The changes that turn `base` ({ path: text }) into `files`; `bins` and `baseBins` ({ path: base64 }) are the small binaries of each. */
29
+ export function changesFrom (base, files, bins = {}, baseBins = {}) {
30
+ const out = []
31
+ for (const [p, t] of Object.entries(files || {})) if (!base || base[p] !== t) out.push(put(p, t))
32
+ for (const [p, d] of Object.entries(bins || {})) if (!(p in (files || {})) && (!base || (baseBins || {})[p] !== d)) out.push({ path: p, op: 'put', content: String(d) })
33
+ const now = (p) => p in (files || {}) || p in (bins || {})
34
+ for (const p of new Set([...Object.keys(base || {}), ...(base ? Object.keys(baseBins || {}) : [])])) if (!now(p)) out.push({ path: p, op: 'delete' })
35
+ return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
36
+ }
37
+
38
+ const taken = (e) => e && (e.status === 409 || e.code === 'conflict' || e.code === 'slug_taken' || /taken|exists|duplicate/i.test(String(e.message)))
39
+
40
+ /**
41
+ * Makes the quilt. `defaultFiles`: { path: text } of the default branch document, `defaultBinaries`
42
+ * its small binaries ({ path: base64 }), `defaultModes` its executables ({ path: 'x' }); `branches`: [{ key, files: { path: text }, binaries? }] for the others; `leftOut`: paths with no text. Returns
43
+ * { quilt_id, slug, patch, squares: [{ from, id }], left_out }. A failure after the quilt was
44
+ * made throws with `quilt_id` on the error, so the caller can name it.
45
+ */
46
+ export const DEFAULT_MERGES = '(default)'
47
+ const OPEN = new Set(['open', 'editing'])
48
+
49
+ /** The open merges of one branch → { terms: conflict changes, left: records with no text to carry }. */
50
+ export function termsFor (merges, files, { square, patchSquare, patch }) {
51
+ const terms = []; const left = []; const seen = new Set()
52
+ for (const m of merges || []) {
53
+ if (!m || !OPEN.has(m.state) || seen.has(m.path)) continue
54
+ if (m.binary || m.local || (m.ours == null && !m.oursDeleted)) { left.push(m); continue }
55
+ seen.add(m.path)
56
+ const now = files && Object.prototype.hasOwnProperty.call(files, m.path) ? files[m.path] : null
57
+ if (now == null && m.oursDeleted) { left.push(m); continue } // both sides gone: nothing conflicts
58
+ terms.push({
59
+ path: m.path,
60
+ op: 'conflict',
61
+ reason: 'import',
62
+ base: m.base != null ? { content: b64(m.base) } : null,
63
+ ours: m.oursDeleted ? null : { content: b64(m.ours), from_square: square, ...(m.by ? { by: String(m.by) } : {}) },
64
+ theirs: now == null ? null : { content: b64(now), ...(patchSquare ? { from_square: patchSquare } : {}), ...(patch ? { from_commit: patch.commit } : {}) }
65
+ })
66
+ }
67
+ return { terms, left }
68
+ }
69
+
70
+ export async function makeQuiltWithoutHistory ({ seam, room, defaultFiles = {}, defaultBinaries = {}, defaultModes = {}, defaultMerges = [], branches = [], leftOut = [], date = new Date() }) {
71
+ const base = slugFor(room)
72
+ let q = null
73
+ for (let n = 1; !q && n <= 20; n++) {
74
+ const slug = n === 1 ? base : `${base}-${n}`
75
+ try { q = await seam.createQuilt({ slug, name: room }) } catch (e) { if (!taken(e) || n === 20) throw e }
76
+ }
77
+ try {
78
+ let patch = null
79
+ let patchSquare = null
80
+ // Executables keep their mode in patch 1 (the document marks them 'x').
81
+ const first = changesFrom(null, defaultFiles, defaultBinaries).map((c) => c.op === 'put' && defaultModes[c.path] === 'x' ? { ...c, mode: 'exec' } : c)
82
+ if (first.length) {
83
+ const day = new Date(date).toISOString().slice(0, 10)
84
+ const s = await seam.openSquare(q.id, { title: `Session ${room}` })
85
+ patchSquare = s.id
86
+ await seam.commit(q.id, s.id, { expect_head: null, message: `Session ${room} as of ${day}`, changes: first })
87
+ const m = await seam.merge(q.id, 'main', { square: s.id })
88
+ if (!m || !m.ok || !m.merged) throw Object.assign(new Error(`Seam did not merge patch 1: ${(m && ((m.refused && m.refused.detail) || m.error || m.code)) || "no answer"}`), { code: m && m.code })
89
+ patch = { no: m.merged.patch_no, commit: m.merged.commit }
90
+ }
91
+ const squares = []
92
+ const terms = []
93
+ const mergesLeft = []
94
+ // Open merges as terms in the square's first commit; the rest of its work stays live.
95
+ const carry = async (from, sq, merges, files) => {
96
+ const t = termsFor(merges, files, { square: sq, patchSquare, patch })
97
+ for (const m of t.left) mergesLeft.push({ from, path: m.path, id: m.id })
98
+ if (!t.terms.length) return new Set()
99
+ await seam.commit(q.id, sq, { expect_head: null, message: `Open merges from session ${room}: ${t.terms.map((x) => x.path).join(', ')}`.slice(0, 2000), changes: t.terms })
100
+ for (const x of t.terms) terms.push({ from, path: x.path, square: sq })
101
+ return new Set(t.terms.map((x) => x.path))
102
+ }
103
+ const defaultLeft = termsFor(defaultMerges, defaultFiles, {})
104
+ if (!defaultLeft.terms.length) for (const m of defaultLeft.left) mergesLeft.push({ from: DEFAULT_MERGES, path: m.path, id: m.id })
105
+ else {
106
+ const s = await seam.openSquare(q.id, { title: `Open merges from session ${room}` })
107
+ const held = await carry(DEFAULT_MERGES, s.id, defaultMerges, defaultFiles)
108
+ if (held.size) squares.push({ from: DEFAULT_MERGES, id: s.id })
109
+ }
110
+ for (const b of branches) {
111
+ const s = await seam.openSquare(q.id, { title: String(b.key).slice(0, 200) })
112
+ const held = await carry(b.key, s.id, b.merges, b.files)
113
+ const changes = changesFrom(defaultFiles, b.files, b.binaries, defaultBinaries).filter((c) => !held.has(c.path))
114
+ if (changes.length) await seam.setLive(q.id, s.id, { changes })
115
+ squares.push({ from: b.key, id: s.id })
116
+ }
117
+ return { quilt_id: q.id, slug: q.slug, patch, squares, terms, merges_left: mergesLeft, left_out: [...leftOut].sort() }
118
+ } catch (e) {
119
+ e.quilt_id = q.id
120
+ e.message = `${e.message} (quilt ${q.slug} was made but the session was not moved into it)`
121
+ throw e
122
+ }
123
+ }