heyquilt 0.3.121 → 0.3.122

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 (48) hide show
  1. package/RELEASES.md +19 -0
  2. package/bin/quilt.js +23 -2
  3. package/package.json +2 -1
  4. package/src/api/memory-store.js +5 -1
  5. package/src/api/routes/seam-connect.js +103 -0
  6. package/src/api/routes/session-migrate.js +36 -0
  7. package/src/api/server.js +37 -9
  8. package/src/api/supabase-store.js +10 -0
  9. package/src/connection.js +4 -1
  10. package/src/control.js +12 -0
  11. package/src/history.js +15 -1
  12. package/src/mcp.js +12 -1
  13. package/src/quilts/accounts.js +136 -0
  14. package/src/quilts/affected.js +132 -0
  15. package/src/quilts/checks.js +308 -0
  16. package/src/quilts/cli.js +97 -10
  17. package/src/quilts/client.js +9 -0
  18. package/src/quilts/describe.js +31 -1
  19. package/src/quilts/events.js +13 -2
  20. package/src/quilts/export-remote.js +156 -0
  21. package/src/quilts/import-follow.js +236 -0
  22. package/src/quilts/import-git.js +6 -3
  23. package/src/quilts/import.js +67 -5
  24. package/src/quilts/index.js +28 -6
  25. package/src/quilts/keys.js +186 -0
  26. package/src/quilts/merge.js +16 -1
  27. package/src/quilts/oauth.js +202 -0
  28. package/src/quilts/reviews.js +18 -5
  29. package/src/quilts/routes.js +61 -1
  30. package/src/quilts/runner.js +95 -0
  31. package/src/quilts/server.js +144 -0
  32. package/src/quilts/service.js +41 -1
  33. package/src/quilts/setup.js +9 -2
  34. package/src/quilts/store-memory.js +164 -3
  35. package/src/quilts/store-rpc.js +12 -1
  36. package/src/quilts/tools.js +39 -11
  37. package/src/quilts/window.js +2 -0
  38. package/src/relay-mcp.js +33 -3
  39. package/src/relay-prune.js +4 -1
  40. package/src/server.js +317 -14
  41. package/src/session-follow.js +81 -0
  42. package/src/session-layout3.js +60 -0
  43. package/src/session-migrate.js +119 -0
  44. package/src/session-quilt.js +145 -0
  45. package/src/session-seam.js +71 -0
  46. package/src/session.js +74 -2
  47. package/src/source-live/live-block.js +4 -3
  48. package/src/source-live/live-square.js +1 -1
package/src/server.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // editors change files, viewers only watch and chat, and agents can be
15
15
  // limited to some folders. The relay enforces it by undoing file changes a
16
16
  // member isn't allowed to make, before anyone else sees them.
17
+ import { quiltOf, gitTokenRefusal } from './session-seam.js'
17
18
  import http from 'node:http'
18
19
  import { makeChatLink, extendChatLink, pruneChatLinks, handleChatLink, fetchPublicFile } from './chat-links.js'
19
20
  import fs from 'node:fs'
@@ -34,7 +35,15 @@ import {
34
35
  ROOM_DOC, syncHeader,
35
36
  syncStep1Message, updateMessage, awarenessMessage, bytesMessage, jsonMessage
36
37
  } from './protocol.js'
37
- import { isLiveKey, liveRequest } from './source-live/live-square.js'
38
+ import { isLiveKey, liveRequest, liveKey, liveMaps } from './source-live/live-square.js'
39
+ import { planSessionQuilt, quiltPlanRefusal, describeQuiltPlan, fileHash } from './session-quilt.js'
40
+ import { makeQuiltWithoutHistory } from './session-migrate.js'
41
+ import { LAYOUT_QUILT, treeIds, overlayFor, writeOverlay, checkOverlay } from './session-layout3.js'
42
+ import { readMerges } from './merges.js'
43
+ import { readTasks, updateTask } from './tasks.js'
44
+ import { addComment } from './task-comments.js'
45
+ // Seam over HTTP through its client package (@heyquilt/seam), never Seam's code.
46
+ import { quiltClient } from '@heyquilt/seam/lib/quilts/client.js'
38
47
  import { BranchStore, DEFAULT_KEY, BRANCH_IDLE_MS, DETACHED_TTL_MS, MAX_BRANCHES, validBranchKey, covers, splitLegacyDoc } from './branchdocs.js'
39
48
  import { parsePublicKey, verifyChallenge } from './identity.js'
40
49
  import { verifyPass, PASS_TTL_MS } from './passes.js'
@@ -1026,7 +1035,7 @@ class Room {
1026
1035
  * are never only in memory, and a crash part-way is finished at the next load.
1027
1036
  */
1028
1037
  migrateLegacy () {
1029
- if (this.meta.layout === 2) return
1038
+ if (this.meta.layout === 2 || this.meta.layout === LAYOUT_QUILT) return
1030
1039
  const legacy = this.doc
1031
1040
  if (this.docFile && (legacy.getMap('files').size || legacy.getMap('blobs').size)) {
1032
1041
  const stored = [...legacy.getMap('blobs').values()].filter((x) => x && x.stored && x.stored.id).map((x) => x.stored.id).sort()
@@ -1384,6 +1393,7 @@ class Room {
1384
1393
 
1385
1394
  /** A room whose move to branch documents failed on the relay's disk (migrateLegacy) stays read-only: no branch document is used. */
1386
1395
  assertMigrated () {
1396
+ if (this.meta.layout === LAYOUT_QUILT) throw new Error(`This session is the quilt ${(this.meta.quilt && this.meta.quilt.slug) || this.meta.quilt_id} now: its files are in its squares, not in branches. Use Seam (squares, commits, merges).`)
1387
1397
  if (this.meta.layout !== 2) throw new Error("The relay couldn't finish updating how it stores this session (its disk refused), so the session is read-only until the relay is fixed.")
1388
1398
  }
1389
1399
 
@@ -1811,8 +1821,18 @@ class Room {
1811
1821
  if (this.cfg.upstreamAuto) this.checkUpstreams().catch(() => {})
1812
1822
  }
1813
1823
 
1824
+ /**
1825
+ * What every branch-list message carries: the list and, once the session is a quilt (behind
1826
+ * QUILT_SOURCE_URL), its link { id, slug, squares: { branch: square id } } so each member's
1827
+ * app and agents can use Seam (plans/quilt-source-control/16).
1828
+ */
1829
+ branchesFields () {
1830
+ const q = this.meta.quilt_id && process.env.QUILT_SOURCE_URL ? { id: this.meta.quilt_id, slug: (this.meta.quilt || {}).slug || '', squares: (this.meta.quilt || {}).squares || {}, ...((this.meta.quilt || {}).at ? { at: this.meta.quilt.at } : {}), ...(this.meta.layout === LAYOUT_QUILT ? { layout: LAYOUT_QUILT, live: this.meta.quilt.live || {}, ...(this.meta.quilt.adoption ? { adoption: this.meta.quilt.adoption } : {}) } : {}) } : null
1831
+ return { branches: this.branchList(), ...(q ? { quilt: q } : {}) }
1832
+ }
1833
+
1814
1834
  broadcastBranches () {
1815
- const msg = jsonMessage(MSG_BRANCHES, { branches: this.branchList() })
1835
+ const msg = jsonMessage(MSG_BRANCHES, { ...this.branchesFields() })
1816
1836
  for (const ws of this.conns.keys()) send(ws, msg)
1817
1837
  }
1818
1838
 
@@ -1838,7 +1858,7 @@ class Room {
1838
1858
 
1839
1859
  /** The relay pushed `sha` to branch `key`'s upstream: folders on it fetch now and follow it (no file is written: the session has it). */
1840
1860
  tellPushed (key, sha) {
1841
- const msg = jsonMessage(MSG_BRANCHES, { branches: this.branchList(), pushed: { branch: key, sha } })
1861
+ const msg = jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), pushed: { branch: key, sha } })
1842
1862
  for (const ws of this.conns.keys()) send(ws, msg)
1843
1863
  }
1844
1864
 
@@ -1961,7 +1981,7 @@ class Room {
1961
1981
  if (head && sync && sync.pushed === head && sync.base === head) { echo = true; this.saveMeta(); continue } // our own push, come back round
1962
1982
  const at = this.cfg.gitSync && this.mirrors && this.meta.sync && this.meta.sync[key] && this.meta.sync[key].base ? this.meta.sync[key].base : rec.sha
1963
1983
  if (head && at === head) { this.saveMeta(); continue } // already there
1964
- const msg = jsonMessage(MSG_BRANCHES, { branches: this.branchList(), pushed: { branch: key, sha: head, ...(forced ? { forced: true } : {}) } })
1984
+ const msg = jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), pushed: { branch: key, sha: head, ...(forced ? { forced: true } : {}) } })
1965
1985
  for (const ws of this.conns.keys()) send(ws, msg)
1966
1986
  this.checkUpstreamsNow([key]).catch((err) => this.log(`[${this.name}] could not look for new commits: ${err.message}`))
1967
1987
  }
@@ -2017,14 +2037,22 @@ class Room {
2017
2037
  * app asks before it clears its folder for a switch).
2018
2038
  */
2019
2039
  branchRequest (ws, req) {
2040
+ // Seam, behind QUILT_SOURCE_URL (plans/quilt-source-control/16): what making this session a
2041
+ // quilt would do, read only. The owner's (or, in a session without one, any editor's).
2042
+ if (req.op === 'quilt-plan') {
2043
+ if (!process.env.QUILT_SOURCE_URL) throw new Error('Seam is not on for this relay, so a session cannot become a quilt here yet')
2044
+ return { ok: true, plan: this.quiltPlan(this.access.get(ws), req.tip) }
2045
+ }
2020
2046
  const key = String(req.branch ?? '')
2021
2047
  if (!validBranchKey(key)) throw new Error(`"${key.slice(0, 60)}" isn't a branch name`)
2022
2048
  if (req.op === 'join') {
2023
- this.assertMigrated()
2049
+ // At layout 3 the only documents members join are squares' live documents (quiltSwitch).
2050
+ const square = this.meta.layout === LAYOUT_QUILT && isLiveKey(key)
2051
+ if (!square) this.assertMigrated()
2024
2052
  // An app from before branch documents names the branch the old document was; else the first real branch takes ∅.
2025
2053
  let adopted = false
2026
- if (validBranchKey(String(req.adopt || ''))) adopted = this.adoptDefault(String(req.adopt))
2027
- adopted = this.adoptDefault(key) || adopted
2054
+ if (!square && validBranchKey(String(req.adopt || ''))) adopted = this.adoptDefault(String(req.adopt))
2055
+ if (!square) adopted = this.adoptDefault(key) || adopted
2028
2056
  const k = this.resolveKey(key)
2029
2057
  const created = !this.meta.branches[k]
2030
2058
  if (created) {
@@ -2095,9 +2123,240 @@ class Room {
2095
2123
  throw new Error('unknown branch request')
2096
2124
  }
2097
2125
 
2126
+ /**
2127
+ * Tasks with a pull request, once the room is a quilt (16's table): one whose branch became a
2128
+ * square is kept in `meta.quilt.tasks` (task id -> square id); one whose branch did not loses
2129
+ * its `pr`, kept as a task comment. Returns the task id -> square id map.
2130
+ */
2131
+ quiltTasks (squares) {
2132
+ const tasks = readTasks(this.tasks)
2133
+ const out = {}
2134
+ for (const t of tasks) {
2135
+ if (!t.pr) continue
2136
+ const sq = t.pr.branch && squares[t.pr.branch]
2137
+ if (sq) { out[t.id] = sq; continue }
2138
+ const which = `${t.pr.number ? ` #${t.pr.number}` : ''}${t.pr.url ? ` (${t.pr.url})` : ''}`
2139
+ updateTask(this.doc, this.tasks, { id: t.id, pr: null }, 'quilt-migrate')
2140
+ addComment(this.doc, this.taskComments, tasks, { taskId: t.id, by: 'Quilt', text: `This task's pull request${which} did not carry over when the session became a quilt: no square was made from its branch${t.pr.branch ? ` ${t.pr.branch}` : ''}.` }, 'quilt-migrate')
2141
+ }
2142
+ return out
2143
+ }
2144
+
2145
+ /**
2146
+ * Open commit requests have no replacement in a quilt (12): each becomes a comment on its task,
2147
+ * or a chat line when it has none, "Please get this into history: <paths>" with its square.
2148
+ */
2149
+ quiltCommitRequests (squares) {
2150
+ const tasks = readTasks(this.tasks)
2151
+ for (const r of this.commitRequests.values()) {
2152
+ if (!r || r.state !== 'open') continue
2153
+ const sq = squares[r.branch || this.defaultKey]
2154
+ const text = `Please get this into history: ${(r.files || []).join(', ')}${sq ? ` (square sq:${sq.slice(0, 8)})` : ''}. ${r.by || 'Someone'} asked for a commit ("${String(r.message || '').slice(0, 200)}") before the session became a quilt: commit it in Seam.`
2155
+ if (r.task && tasks.some((t) => t.id === r.task.id)) addComment(this.doc, this.taskComments, tasks, { taskId: r.task.id, by: 'Quilt', text }, 'quilt-migrate')
2156
+ else this.postChat({ by: 'Quilt', text, kind: 'note' })
2157
+ }
2158
+ }
2159
+
2160
+ /**
2161
+ * The owner's app asks to make the session a quilt (`op: 'quilt-migrate'`, `token`: their Seam
2162
+ * sign-in). Answered when it is done, on the branch channel like every branch request.
2163
+ */
2164
+ quiltMigrateRequest (ws, req) {
2165
+ const answer = (fields) => send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), reply: { id: req.id, op: req.op, ...fields } }))
2166
+ const api = process.env.QUILT_SOURCE_URL
2167
+ if (!api) return answer({ ok: false, error: 'Seam is not on for this relay, so a session cannot become a quilt here yet' })
2168
+ if (!req.token || typeof req.token !== 'string') return answer({ ok: false, error: 'sign in to Seam first: making a session a quilt is done as its owner' })
2169
+ const seam = (this.seamClient || quiltClient)({ api, token: req.token })
2170
+ if (req.op === 'quilt-switch') {
2171
+ return this.quiltSwitch(this.access.get(ws), seam).then((r) => answer({ ok: true, ...r }), (err) => answer({ ok: false, error: err.message }))
2172
+ }
2173
+ this.quiltMigrate(this.access.get(ws), seam).then(
2174
+ (r) => answer({ ok: true, quilt: r }),
2175
+ (err) => answer({ ok: false, error: err.message, ...(err.quilt_id ? { quilt_id: err.quilt_id } : {}) })
2176
+ )
2177
+ }
2178
+
2179
+ /** The dry run of making this session a quilt, for someone with access `a` (session-quilt.js). */
2180
+ quiltPlan (a, tip = null) {
2181
+ const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
2182
+ if (no) throw new Error(no)
2183
+ this.assertMigrated()
2184
+ return planSessionQuilt(this.quiltSnapshot(), { tip: tip && typeof tip === 'object' ? tip : null })
2185
+ }
2186
+
2187
+ /**
2188
+ * The room as session-quilt.js's dry run reads it: each branch document's files (by hash),
2189
+ * history and merge records, the claims, tasks with pull requests, and commit requests.
2190
+ * Live square documents (quilt/<id>) are already Seam's and are left out.
2191
+ */
2192
+ quiltSnapshot () {
2193
+ const branches = []
2194
+ for (const [key, b] of Object.entries(this.meta.branches || {})) {
2195
+ if (isLiveKey(key)) continue
2196
+ const e = this.store.load(key)
2197
+ if (!e) continue
2198
+ const files = {}
2199
+ for (const [p, t] of e.files) { const s = t.toString(); files[p] = { hash: fileHash(s), bytes: Buffer.byteLength(s) } }
2200
+ for (const [p, x] of e.blobs) if (x && !files[p]) files[p] = { hash: String(x.hash || x.sha256 || ''), bytes: Number(x.size) || 0 }
2201
+ const merges = [...e.doc.getMap('merges').values()].map((m) => ({ id: m && m.id, state: m && m.state }))
2202
+ branches.push({ key, by: b.by || '', files, history: e.doc.getArray('history').toArray(), merges })
2203
+ }
2204
+ const claims = Object.values(this.meta.claims || {}).map((c) => ({ pattern: c.pattern, branch: this.branchOf(c), by: c.by || '', byId: c.byId || '', queue: c.queue || [] }))
2205
+ const tasks = [...this.tasks.entries()].map(([id, t]) => ({ id, pr: t && typeof t.get === 'function' ? t.get('pr') : t && t.pr })).filter((t) => t.pr)
2206
+ return {
2207
+ room: this.name,
2208
+ owner: this.meta.ownerSub || this.meta.ownerName || this.meta.owner || '',
2209
+ defaultKey: this.defaultKey,
2210
+ hasRemote: Object.keys(this.meta.upstreams || {}).length > 0,
2211
+ branches,
2212
+ claims,
2213
+ tasks,
2214
+ commitRequests: this.doc.getMap('commitRequests').size
2215
+ }
2216
+ }
2217
+
2218
+ /**
2219
+ * Makes this session a quilt (plans/quilt-source-control/16, "Without history"), behind
2220
+ * QUILT_SOURCE_URL: the owner's (as quiltPlan), with `seam` a Seam client acting as them.
2221
+ * The room is linked (`meta.quilt_id`, `meta.quilt`) only when every step worked, so a
2222
+ * failure leaves it a git-mode session as before. A room with a git remote goes with history
2223
+ * (the import and the cut-over, 14 and 16 step 3), not here.
2224
+ */
2225
+ async quiltMigrate (a, seam, { date = new Date() } = {}) {
2226
+ const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
2227
+ if (no) throw new Error(no.replace('see what making it a quilt would do', 'make it a quilt'))
2228
+ this.assertMigrated()
2229
+ if (this.meta.quilt_id) throw new Error(`this session is already the quilt ${(this.meta.quilt && this.meta.quilt.slug) || this.meta.quilt_id}`)
2230
+ if (Object.keys(this.meta.upstreams || {}).length) throw new Error('this session has a git remote: it becomes a quilt with its history, by importing the remote and cutting over (docs/seam-cutover.md)')
2231
+ if (this.quiltMoving) throw new Error('this session is already being made a quilt')
2232
+ this.quiltMoving = true
2233
+ try {
2234
+ const texts = (key) => {
2235
+ const e = this.store.load(key)
2236
+ const files = {}; const big = []
2237
+ if (!e) return { files, big, merges: [] }
2238
+ for (const [p, t] of e.files) files[p] = t.toString()
2239
+ for (const [p, x] of e.blobs) if (x && !(p in files)) big.push(p)
2240
+ return { files, big, merges: e.doc ? readMerges(e.doc.getMap('merges')) : [] }
2241
+ }
2242
+ const def = texts(this.defaultKey)
2243
+ const leftOut = new Set(def.big)
2244
+ const branches = []
2245
+ for (const key of Object.keys(this.meta.branches || {})) {
2246
+ if (key === this.defaultKey || isLiveKey(key)) continue
2247
+ const t = texts(key)
2248
+ for (const p of t.big) leftOut.add(`${key}:${p}`)
2249
+ branches.push({ key, files: t.files, merges: t.merges })
2250
+ }
2251
+ const r = await makeQuiltWithoutHistory({ seam, room: this.name, defaultFiles: def.files, defaultMerges: def.merges, branches, leftOut: [...leftOut], date })
2252
+ this.meta.quilt_id = r.quilt_id
2253
+ this.meta.quilt = { slug: r.slug, patch: r.patch, squares: Object.fromEntries(r.squares.map((s) => [s.from, s.id])), at: new Date(date).toISOString(), by: (a && (a.sub || a.key || a.name)) || '' }
2254
+ // Open merges now live in Seam as conflict terms; the ones with no text to carry are named.
2255
+ if (r.terms.length || r.merges_left.length) this.meta.quilt.merges = { terms: r.terms, left: r.merges_left }
2256
+ this.meta.quilt.tasks = this.quiltTasks(this.meta.quilt.squares)
2257
+ this.quiltCommitRequests(this.meta.quilt.squares)
2258
+ this.saveMeta()
2259
+ this.broadcastBranches()
2260
+ return { ...r, tasks: this.meta.quilt.tasks }
2261
+ } finally {
2262
+ this.quiltMoving = false
2263
+ }
2264
+ }
2265
+
2266
+ /**
2267
+ * The layout-3 switch (16, "How a session becomes a quilt"), after quiltMigrate linked the
2268
+ * quilt: each branch document is copied into its square's live document on the relay
2269
+ * (`quilt/<square id>`, an overlay over the square's head), the default branch's difference
2270
+ * from `main` into an adoption square ("Uncommitted work from <room>"), every copy is checked
2271
+ * against its branch document, and only then `meta.layout = 3` is written. A failed check
2272
+ * removes the copies and leaves the room at layout 2. The branch documents are not touched: at
2273
+ * layout 3 they are read-only, and quiltUnswitch goes back to them. Apps without `quilts` are
2274
+ * sent away (supported). The owner's, with `seam` acting as them, behind QUILT_SOURCE_URL.
2275
+ */
2276
+ async quiltSwitch (a, seam, { date = new Date() } = {}) {
2277
+ const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
2278
+ if (no) throw new Error(no.replace('see what making it a quilt would do', 'move its files into its squares'))
2279
+ if (this.meta.layout === LAYOUT_QUILT) throw new Error('this session\'s files are already in its squares')
2280
+ this.assertMigrated()
2281
+ const q = this.meta.quilt_id
2282
+ if (!q || !this.meta.quilt) throw new Error('this session is not a quilt yet: make it one first (quilt session migrate)')
2283
+ if (this.quiltMoving) throw new Error('this session is already being moved')
2284
+ this.quiltMoving = true
2285
+ const made = []
2286
+ const by = (a && (a.name || a.sub || a.key)) || ''
2287
+ try {
2288
+ const texts = (key) => {
2289
+ const e = this.store.load(key)
2290
+ const files = {}
2291
+ if (e) for (const [p, t] of e.files) files[p] = t.toString()
2292
+ return files
2293
+ }
2294
+ const held = (from) => new Set(((this.meta.quilt.merges && this.meta.quilt.merges.terms) || []).filter((t) => t.from === from).map((t) => t.path))
2295
+ const without = (o, skip) => Object.fromEntries(Object.entries(o).filter(([p]) => !skip.has(p)))
2296
+ const plan = []
2297
+ for (const [key, sq] of Object.entries(this.meta.quilt.squares || {})) {
2298
+ if (key === this.defaultKey || !this.meta.branches[key] || isLiveKey(key)) continue
2299
+ const s = await seam.square(q, sq)
2300
+ const head = s.head || s.base || null
2301
+ const skip = held(key)
2302
+ plan.push({ key, sq, head, files: without(texts(key), skip), tree: without(head ? treeIds(await seam.tree(q, head)) : {}, skip) })
2303
+ }
2304
+ const def = texts(this.defaultKey)
2305
+ const main = this.meta.quilt.patch ? treeIds(await seam.tree(q, this.meta.quilt.patch.commit)) : {}
2306
+ const adopt = overlayFor(def, main)
2307
+ let adoption = null
2308
+ if (Object.keys(adopt.puts).length || adopt.gone.length) {
2309
+ const s = await seam.openSquare(q, { title: `Uncommitted work from ${this.name}`.slice(0, 200) })
2310
+ adoption = s.id
2311
+ plan.push({ key: this.defaultKey, sq: s.id, head: (this.meta.quilt.patch && this.meta.quilt.patch.commit) || null, files: def, tree: main })
2312
+ }
2313
+ for (const p of plan) {
2314
+ const lk = liveKey(p.sq)
2315
+ if (this.meta.branches[lk] && this.store.load(lk) && liveMaps(this.store.load(lk).doc).live.size) throw new Error(`square ${p.sq.slice(0, 8)} already has a live document on the relay`)
2316
+ this.noteBranch(lk, { by })
2317
+ made.push(lk)
2318
+ writeOverlay(this.store.load(lk).doc, overlayFor(p.files, p.tree), p.head, by)
2319
+ }
2320
+ for (const p of plan) {
2321
+ const bad = checkOverlay(this.store.load(liveKey(p.sq)).doc, p.files, p.tree)
2322
+ if (bad) throw new Error(`the check after copying ${p.key === this.defaultKey ? 'the default branch' : p.key} failed: ${bad}`)
2323
+ }
2324
+ this.meta.quilt.live = Object.fromEntries(plan.map((p) => [p.key, liveKey(p.sq)]))
2325
+ if (adoption) this.meta.quilt.adoption = adoption
2326
+ this.meta.quilt.layoutAt = new Date(date).toISOString()
2327
+ this.meta.layout = LAYOUT_QUILT // last
2328
+ this.saveMeta()
2329
+ for (const ws of [...this.conns.keys(), ...this.pending.keys()]) {
2330
+ if (!ws.hosted && !this.supported(ws)) { ws.endReason = 'needs_update'; ws.close(CLOSE_NEEDS_UPDATE, NEEDS_UPDATE) }
2331
+ }
2332
+ this.broadcastBranches()
2333
+ return { layout: LAYOUT_QUILT, live: this.meta.quilt.live, adoption }
2334
+ } catch (err) {
2335
+ for (const lk of made) this.removeBranch(lk)
2336
+ if (made.length) this.broadcastBranches()
2337
+ throw new Error(`Migration undone: ${err.message}. The session stays as it was.`)
2338
+ } finally {
2339
+ this.quiltMoving = false
2340
+ }
2341
+ }
2342
+
2343
+ /** Back to layout 2 from the kept branch documents (16's rollback before R3). The squares' live documents stay. */
2344
+ quiltUnswitch (a) {
2345
+ const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
2346
+ if (no) throw new Error(no.replace('see what making it a quilt would do', 'move it back to branches'))
2347
+ if (this.meta.layout !== LAYOUT_QUILT) throw new Error('this session\'s files are not in squares')
2348
+ this.meta.layout = 2
2349
+ this.meta.quilt.unswitchedAt = new Date().toISOString()
2350
+ this.saveMeta()
2351
+ this.broadcastBranches()
2352
+ return { layout: 2 }
2353
+ }
2354
+
2098
2355
  /** Can this connection's app handle the session as it is now? */
2099
2356
  supported (ws) {
2100
- return !this.meta.largeFiles || (ws.features || []).includes('large-files')
2357
+ const f = ws.features || []
2358
+ if (this.meta.layout === LAYOUT_QUILT && !f.includes('quilts')) return false
2359
+ return !this.meta.largeFiles || f.includes('large-files')
2101
2360
  }
2102
2361
 
2103
2362
  /**
@@ -2975,7 +3234,7 @@ class Room {
2975
3234
  this.conns.set(ws, new Set())
2976
3235
  this.names.set(ws, name)
2977
3236
  send(ws, syncStep1Message(this.doc))
2978
- send(ws, jsonMessage(MSG_BRANCHES, { branches: this.branchList() }))
3237
+ send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields() }))
2979
3238
  const states = [...this.awareness.getStates().keys()]
2980
3239
  if (states.length) send(ws, awarenessMessage(this.awareness, states))
2981
3240
  send(ws, jsonMessage(MSG_CLAIMS, { claims: this.claimList(ws.branch) }))
@@ -3051,6 +3310,8 @@ class Room {
3051
3310
  // A branch over its size limit takes nothing new; the room and every other branch carry
3052
3311
  // on, and nobody is disconnected for it (its members hear it in the branch list).
3053
3312
  if (!this.unsavable && this.branchFull(k) && decoding.peekVarUint(dec) !== syncProtocol.messageYjsSyncStep1) return
3313
+ // At layout 3 the branch documents are kept read-only: the work is in the squares (quiltSwitch).
3314
+ if (this.meta.layout === LAYOUT_QUILT && !isLiveKey(k) && decoding.peekVarUint(dec) !== syncProtocol.messageYjsSyncStep1) return
3054
3315
  }
3055
3316
  if (docId === ROOM_DOC ? this.full : this.unsavable) {
3056
3317
  // Over quota: still answer "what do you have?" so people can read, but refuse new data.
@@ -3069,13 +3330,15 @@ class Room {
3069
3330
  let reply
3070
3331
  try {
3071
3332
  req = JSON.parse(decoding.readVarString(dec))
3072
- reply = { id: req.id, op: req.op, ...this.branchRequest(ws, req) }
3333
+ if (req.op === 'quilt-migrate' || req.op === 'quilt-switch') { this.quiltMigrateRequest(ws, req); return }
3334
+ if (req.op === 'quilt-unswitch') reply = { id: req.id, op: req.op, ok: true, ...this.quiltUnswitch(this.access.get(ws)) }
3335
+ else reply = { id: req.id, op: req.op, ...this.branchRequest(ws, req) }
3073
3336
  } catch (err) {
3074
3337
  reply = { id: req.id, op: req.op, ok: false, error: err.message }
3075
3338
  }
3076
3339
  const listChanged = reply.listChanged
3077
3340
  delete reply.listChanged
3078
- send(ws, jsonMessage(MSG_BRANCHES, { branches: this.branchList(), reply }))
3341
+ send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), reply }))
3079
3342
  if (reply.ok && reply.op === 'join') {
3080
3343
  // What the relay has of the branch, and its claims: the app answers with what it has.
3081
3344
  send(ws, syncStep1Message(this.store.get(ws.branch).doc, ws.branchAs))
@@ -3127,7 +3390,9 @@ class Room {
3127
3390
  const id = req.id
3128
3391
  const a = this.access.get(ws)
3129
3392
  if (!a || !ws.branch) throw new Error('not in this session')
3130
- this.gitTokenFor(ws.branch, { access: req.access === 'write' ? 'write' : 'read', who: { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', id: a.id }, repo: typeof req.repo === 'string' ? req.repo : null })
3393
+ // In a session that is a quilt, an agent's git gets no GitHub token: it uses Seam (session-seam.js).
3394
+ const onSeam = gitTokenRefusal(quiltOf(this.meta), a.kind === 'agent' ? 'agent' : 'human')
3395
+ ;(onSeam ? Promise.reject(new Error(onSeam)) : this.gitTokenFor(ws.branch, { access: req.access === 'write' ? 'write' : 'read', who: { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', id: a.id }, repo: typeof req.repo === 'string' ? req.repo : null }))
3131
3396
  .then((git) => ({ id, ok: true, git }), (err) => ({ id, ok: false, error: err.message }))
3132
3397
  .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
3133
3398
  return
@@ -4036,6 +4301,44 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4036
4301
  json(200, { ok: true, rooms: n })
4037
4302
  })
4038
4303
  }
4304
+ // Making a session a quilt from the accounts API (POST /v1/sessions/:room/migrate, which checked the
4305
+ // owner's sign-in): { room, account, dry_run } plans it; { room, account, token } makes it, as the
4306
+ // owner, with their Seam sign-in. The room's own owner check still applies (Room.quiltPlan).
4307
+ if (url.pathname === '/quilt/migrate' && req.method === 'POST') {
4308
+ if (!fromApi(req)) return json(401, { error: 'only the accounts API may say this' })
4309
+ return readJson(req, 16 * 1024, (err, b) => {
4310
+ if (err || !b) return json(400, { error: 'send JSON' })
4311
+ const name = String(b.room || '')
4312
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return json(400, { error: 'give the room' })
4313
+ if (!/^[a-z]+:[A-Za-z0-9_-]{1,64}$/.test(String(b.account || ''))) return json(400, { error: 'give the account' })
4314
+ const api = process.env.QUILT_SOURCE_URL
4315
+ if (!api) return json(409, { error: 'Seam is not on for this relay, so a session cannot become a quilt here yet' })
4316
+ const onDisk = dataDir && fs.existsSync(path.join(dataDir, `${name}.json`))
4317
+ if ((!rooms.has(name) && !onDisk) || roomEnded(name)) return json(404, { error: 'no such session' })
4318
+ const room = getRoom(name)
4319
+ if (!room) return json(409, { error: 'this session is not on this relay machine' })
4320
+ const access = { state: 'approved', role: 'editor', scopes: [], owner: !room.controlled || room.meta.ownerSub === b.account, sub: b.account }
4321
+ if (room.controlled && !access.owner) return json(403, { error: 'only the session\'s owner can make it a quilt' })
4322
+ if (b.dry_run === true) {
4323
+ try {
4324
+ const plan = room.quiltPlan(access, b.tip && typeof b.tip === 'object' ? b.tip : null)
4325
+ return json(200, { ok: true, plan, text: describeQuiltPlan(plan) })
4326
+ } catch (e) { return json(409, { error: e.message }) }
4327
+ }
4328
+ if (b.step === 'unswitch') {
4329
+ try { return json(200, { ok: true, ...room.quiltUnswitch(access) }) } catch (e) { return json(409, { error: e.message }) }
4330
+ }
4331
+ if (!b.token || typeof b.token !== 'string') return json(400, { error: 'sign in to Seam first: making a session a quilt is done as its owner' })
4332
+ const seam = (room.seamClient || quiltClient)({ api, token: b.token })
4333
+ if (b.step === 'switch') {
4334
+ return room.quiltSwitch(access, seam).then((r) => json(200, { ok: true, ...r }), (e) => json(409, { error: e.message }))
4335
+ }
4336
+ room.quiltMigrate(access, seam).then(
4337
+ (quilt) => json(200, { ok: true, quilt }),
4338
+ (e) => json(409, { error: e.message, ...(e.quilt_id ? { quilt_id: e.quilt_id } : {}) })
4339
+ )
4340
+ })
4341
+ }
4039
4342
  // Moving a room between machines (the accounts API runs it, src/api/relay-cluster.js): the source
4040
4343
  // lets it go, the target copies it from the source, the source forgets its copy (or takes it back).
4041
4344
  const cm = url.pathname.match(/^\/cluster\/rooms\/([A-Za-z0-9_-]{1,64})\/(release|export|fetch|forget|restore)$/)
@@ -4379,7 +4682,7 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4379
4682
  return reject(socket, auth === 'need-key' ? 403 : 401, auth === 'need-key' ? 'Relay key required to create rooms' : 'Wrong room secret')
4380
4683
  }
4381
4684
  // Checked after the secret, so only members learn what the session needs.
4382
- if (room.meta.largeFiles && !features.includes('large-files')) {
4685
+ if ((room.meta.largeFiles && !features.includes('large-files')) || (room.meta.layout === LAYOUT_QUILT && !features.includes('quilts'))) {
4383
4686
  if (!room.conns.size && room.onEmpty) room.onEmpty() // don't keep it in memory for nobody
4384
4687
  return reject(socket, 400, NEEDS_UPDATE)
4385
4688
  }
@@ -0,0 +1,81 @@
1
+ // Quilt's side of Seam (plans/quilt-source-control/16, "How a session becomes a quilt"): once a
2
+ // session is switched to layout 3 (session-layout3.js), each branch's live work is a square's live
3
+ // document on the relay (`quilt/<square>`), and a folder follows it through a live block
4
+ // (source-live/live-block.js): its files mirror the square, every edit goes into the live document.
5
+ //
6
+ // SquareFollower is that link for one Session: `follow({ dir, client })` opens the block for the
7
+ // session's branch, and the relay's `quilt` message (switched back, square moved) or the session
8
+ // stopping closes it. Nothing here runs unless QUILT_SOURCE_URL is set and the relay says the
9
+ // session is a quilt at layout 3, so GitHub-backed sessions never reach it.
10
+ import path from 'node:path'
11
+ import { FEATURES } from './protocol.js'
12
+ import { openLiveBlock } from './source-live/live-block.js'
13
+
14
+ const LAYOUT_QUILT = 3
15
+ const PREFIX = 'quilt/'
16
+
17
+ /** The square the folder on `branch` follows: `{ square, key }`, or null while the session is not a layout-3 quilt. */
18
+ export function squareFor (quilt, branch) {
19
+ if (!quilt || quilt.layout !== LAYOUT_QUILT || !branch) return null
20
+ const key = (quilt.live || {})[branch]
21
+ if (typeof key !== 'string' || !key.startsWith(PREFIX) || key.length === PREFIX.length) return null
22
+ return { square: key.slice(PREFIX.length), key }
23
+ }
24
+
25
+ const withQuilts = (f) => (String(f || FEATURES).split(',').includes('quilts') ? String(f || FEATURES) : `${f || FEATURES},quilts`)
26
+
27
+ export class SquareFollower {
28
+ /** @param {object} session a Session (server, room, secret, name, identity, passes, tool, kind, features, quilt, branchKey(), log) */
29
+ constructor (session, { open = openLiveBlock } = {}) {
30
+ this.session = session
31
+ this.open = open
32
+ this.following = null // { square, dir, block }
33
+ }
34
+
35
+ /** Opens the block at `dir` (its own folder, signed in to Seam with `client`) on the session's branch's square. */
36
+ async follow ({ dir, client } = {}) {
37
+ if (!process.env.QUILT_SOURCE_URL) throw new Error('Seam is not on for this app (QUILT_SOURCE_URL is not set).')
38
+ if (!dir) throw new Error('Give the folder the square\'s files go to.')
39
+ const s = this.session
40
+ // Its own folder: inside the session's folder, the block's files would sync a second time.
41
+ if (s.root) {
42
+ const rel = path.relative(path.resolve(s.root), path.resolve(dir))
43
+ if (!rel || (!rel.startsWith('..') && !path.isAbsolute(rel))) throw new Error('Give a folder outside this session\'s folder: the square\'s files are synced there by Seam, not by this folder.')
44
+ }
45
+ if (!s.quilt) throw new Error('This session is not a quilt.')
46
+ const branch = s.branchKey()
47
+ const at = squareFor(s.quilt, branch)
48
+ if (!at) throw new Error(`The session is a quilt, but ${branch} has no live square yet (the owner switches it with quilt session migrate --switch).`)
49
+ if (this.following && this.following.square === at.square && this.following.dir === dir) return { square: at.square, dir }
50
+ await this.close()
51
+ const block = await this.open({
52
+ dir,
53
+ client,
54
+ quilt: s.quilt.id,
55
+ square: at.square,
56
+ api: process.env.QUILT_SOURCE_URL,
57
+ relay: { server: s.server, room: s.room, secret: s.secret, name: s.name, identity: s.identity, passes: s.passes, tool: s.tool, kind: s.kind, features: withQuilts(s.features) }
58
+ })
59
+ this.following = { square: at.square, dir, block }
60
+ if (s.log) s.log(`🧵 ${dir} follows the square ${at.square} of ${s.quilt.slug || s.quilt.id}: edits there are live for everyone on it.`)
61
+ return { square: at.square, dir }
62
+ }
63
+
64
+ /** The relay's `quilt` message: let go of a square the branch no longer has (switched back, moved). */
65
+ async onQuilt (quilt) {
66
+ if (!this.following) return
67
+ const at = squareFor(quilt, this.session.branchKey())
68
+ if (at && at.square === this.following.square) return
69
+ const was = this.following.square
70
+ await this.close()
71
+ if (this.session.log) this.session.log(`🧵 stopped following the square ${was}: ${at ? `the branch is on ${at.square} now` : 'the session is back on its branch documents'}.`)
72
+ }
73
+
74
+ async close () {
75
+ const f = this.following
76
+ this.following = null
77
+ if (f && f.block && f.block.close) await f.block.close().catch(() => {})
78
+ }
79
+
80
+ stop () { return this.close() }
81
+ }
@@ -0,0 +1,60 @@
1
+ // Quilt's side of Seam: the room's switch to layout 3 (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 * as Y from 'yjs'
10
+ import { blobId } from '@heyquilt/seam/lib/quilts/records.js'
11
+ import { liveMaps, startHead, entryOf, entryId } from './source-live/live-square.js'
12
+
13
+ export const LAYOUT_QUILT = 3
14
+
15
+ const idOf = (text) => blobId(Buffer.from(String(text), 'utf8'))
16
+
17
+ /** A Seam tree answer ({ entries: [{ path, blob, mode }] }) as path -> blob id (conflict terms by term id). */
18
+ export function treeIds (tree) {
19
+ const out = {}
20
+ for (const e of (tree && tree.entries) || []) out[e.path] = e.blob || e.term || null
21
+ return out
22
+ }
23
+
24
+ /** What turns `head` (path -> id) into `files` (path -> text): { puts: { path: text }, gone: [path] }. */
25
+ export function overlayFor (files, head) {
26
+ const puts = {}
27
+ for (const [p, t] of Object.entries(files || {})) if (head[p] !== idOf(t)) puts[p] = t
28
+ const gone = Object.keys(head || {}).filter((p) => !Object.prototype.hasOwnProperty.call(files || {}, p)).sort()
29
+ return { puts, gone }
30
+ }
31
+
32
+ /** Writes an overlay into an empty live document, with `head` as its first head. */
33
+ export function writeOverlay (doc, { puts, gone }, head, by = '') {
34
+ const m = liveMaps(doc)
35
+ doc.transact(() => {
36
+ for (const [p, t] of Object.entries(puts)) { const y = new Y.Text(); y.insert(0, t); m.files.set(p, y) }
37
+ for (const p of gone) m.gone.set(p, { by, ts: Date.now() })
38
+ }, 'relay-quilt')
39
+ if (head) startHead(doc, head, by)
40
+ }
41
+
42
+ /**
43
+ * The post-copy check (16, "Dry run and check"): the live document over its head holds exactly
44
+ * the branch document's files, by id. Returns null, or the first difference in words.
45
+ */
46
+ export function checkOverlay (doc, files, head) {
47
+ const want = {}
48
+ for (const [p, t] of Object.entries(files || {})) want[p] = idOf(t)
49
+ const have = { ...head }
50
+ const m = liveMaps(doc)
51
+ for (const p of new Set([...m.files.keys(), ...m.blobs.keys(), ...m.gone.keys()])) {
52
+ const e = entryOf(doc, p)
53
+ if (!e) continue
54
+ if (e.op === 'delete') delete have[p]; else have[p] = entryId(e)
55
+ }
56
+ for (const p of new Set([...Object.keys(want), ...Object.keys(have)])) {
57
+ 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`
58
+ }
59
+ return null
60
+ }