heyquilt 0.3.122 → 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 (136) hide show
  1. package/RELEASES.md +273 -17
  2. package/bin/quilt.js +55 -9
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -2
  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 +16 -9
  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/session-migrate.js +7 -4
  15. package/src/api/routes/sessions.js +73 -1
  16. package/src/api/server.js +13 -7
  17. package/src/api/supabase-store.js +43 -4
  18. package/src/autowork.js +182 -16
  19. package/src/connection.js +36 -9
  20. package/src/control.js +129 -15
  21. package/src/force-date.js +27 -0
  22. package/src/fsutil.js +13 -3
  23. package/src/gitstate.js +38 -2
  24. package/src/history.js +13 -3
  25. package/src/mcp.js +87 -12
  26. package/src/merge3.js +11 -3
  27. package/src/now-summary.js +113 -0
  28. package/src/pathrules.js +12 -0
  29. package/src/protocol.js +10 -2
  30. package/src/quilt-default.js +84 -0
  31. package/src/quilt-history.js +106 -0
  32. package/src/quilt-remote.js +78 -0
  33. package/src/relay-commit.js +5 -0
  34. package/src/relay-lease.js +6 -2
  35. package/src/relay-mcp.js +151 -17
  36. package/src/relay-outbound.js +1 -1
  37. package/src/release-words.js +2 -2
  38. package/src/runner.js +20 -7
  39. package/src/seam-access.js +97 -0
  40. package/src/seam-claims.js +53 -0
  41. package/src/seam-url.js +15 -0
  42. package/src/server.js +1517 -86
  43. package/src/session-follow.js +461 -27
  44. package/src/session-layout3.js +410 -4
  45. package/src/session-migrate.js +15 -11
  46. package/src/session-quilt.js +1 -1
  47. package/src/session-seam.js +233 -20
  48. package/src/session.js +848 -67
  49. package/src/source-live/bring-in.js +283 -0
  50. package/src/source-live/live-block.js +504 -33
  51. package/src/source-live/live-square.js +16 -5
  52. package/src/status.js +5 -0
  53. package/src/ui/agent-guide.js +1 -1
  54. package/src/ui/app.css +85 -20
  55. package/src/ui/app.js +4 -1
  56. package/src/ui/chat.js +9 -3
  57. package/src/ui/cloud-ai-model.js +13 -0
  58. package/src/ui/cloud-ai.js +10 -2
  59. package/src/ui/common.js +6 -2
  60. package/src/ui/fileview.js +20 -7
  61. package/src/ui/force-words.js +19 -0
  62. package/src/ui/git-rule.js +9 -0
  63. package/src/ui/home.js +0 -147
  64. package/src/ui/loom-model.js +15 -1
  65. package/src/ui/quiltview.js +213 -0
  66. package/src/ui/seam-model.js +251 -0
  67. package/src/ui/session.js +413 -31
  68. package/src/ui/voice-call.js +83 -14
  69. package/src/ui/voice-model.js +39 -0
  70. package/src/ui/voice.js +167 -17
  71. package/src/ui-server.js +80 -28
  72. package/src/update-check.js +25 -1
  73. package/src/voice.js +13 -6
  74. package/src/git.js +0 -147
  75. package/src/quilts/AGENTS.md +0 -1
  76. package/src/quilts/CLAUDE.md +0 -1
  77. package/src/quilts/accounts.js +0 -136
  78. package/src/quilts/affected.js +0 -132
  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/checks.js +0 -308
  87. package/src/quilts/claims.js +0 -144
  88. package/src/quilts/cli.js +0 -536
  89. package/src/quilts/client.js +0 -137
  90. package/src/quilts/conflict-format.js +0 -190
  91. package/src/quilts/describe.js +0 -296
  92. package/src/quilts/dogfood.js +0 -82
  93. package/src/quilts/errors.js +0 -8
  94. package/src/quilts/events.js +0 -171
  95. package/src/quilts/export-git.js +0 -130
  96. package/src/quilts/export-remote.js +0 -156
  97. package/src/quilts/export.js +0 -81
  98. package/src/quilts/fastmerge.js +0 -41
  99. package/src/quilts/hash.js +0 -44
  100. package/src/quilts/history.js +0 -266
  101. package/src/quilts/ids.js +0 -12
  102. package/src/quilts/import-follow.js +0 -236
  103. package/src/quilts/import-git.js +0 -294
  104. package/src/quilts/import.js +0 -196
  105. package/src/quilts/index.js +0 -94
  106. package/src/quilts/keys.js +0 -186
  107. package/src/quilts/limits.js +0 -67
  108. package/src/quilts/lines.js +0 -49
  109. package/src/quilts/live.js +0 -50
  110. package/src/quilts/merge-smart.js +0 -114
  111. package/src/quilts/merge.js +0 -501
  112. package/src/quilts/oauth.js +0 -202
  113. package/src/quilts/objects.js +0 -144
  114. package/src/quilts/pack.js +0 -118
  115. package/src/quilts/records.js +0 -211
  116. package/src/quilts/resolutions.js +0 -69
  117. package/src/quilts/reviews.js +0 -208
  118. package/src/quilts/routes.js +0 -186
  119. package/src/quilts/runner.js +0 -95
  120. package/src/quilts/server.js +0 -144
  121. package/src/quilts/service.js +0 -963
  122. package/src/quilts/setup.js +0 -41
  123. package/src/quilts/signing.js +0 -149
  124. package/src/quilts/squares.js +0 -239
  125. package/src/quilts/store-memory.js +0 -844
  126. package/src/quilts/store-rpc.js +0 -90
  127. package/src/quilts/store-supabase.js +0 -13
  128. package/src/quilts/tags.js +0 -64
  129. package/src/quilts/textdiff.js +0 -96
  130. package/src/quilts/tokens.js +0 -66
  131. package/src/quilts/tools.js +0 -403
  132. package/src/quilts/tree.js +0 -351
  133. package/src/quilts/webhook.js +0 -116
  134. package/src/quilts/window.js +0 -121
  135. package/src/quilts/xdiff.js +0 -435
  136. package/src/ui/branches.js +0 -111
package/src/server.js CHANGED
@@ -26,7 +26,7 @@ import { WebSocketServer } from 'ws'
26
26
  import * as Y from 'yjs'
27
27
  import { handleAgentMcp, handleHostedMcp, watchFeatures } from './relay-mcp.js'
28
28
  import { dropCheckpoints } from './relay-turn-sync.js'
29
- import { UpdateCheck } from './update-check.js'
29
+ import { UpdateCheck, needsUpdateWords, withUpdateSteps } from './update-check.js'
30
30
  import {
31
31
  MSG_SYNC, MSG_AWARENESS, MSG_QUERY_AWARENESS, MSG_AUTH, MSG_CLAIM, MSG_CLAIMS, MAX_SHARED_FILE_BYTES,
32
32
  MSG_ACCESS, MSG_ADMIN, MSG_MEMBERS, MSG_PASS, MSG_BRANCH, MSG_BRANCHES,
@@ -35,10 +35,17 @@ import {
35
35
  ROOM_DOC, syncHeader,
36
36
  syncStep1Message, updateMessage, awarenessMessage, bytesMessage, jsonMessage
37
37
  } from './protocol.js'
38
- import { isLiveKey, liveRequest, liveKey, liveMaps } from './source-live/live-square.js'
38
+ import { isLiveKey, liveRequest, liveKey, liveMaps, liveHead, overlay, seal, entryId, startHead } from './source-live/live-square.js'
39
+ import { conflictsAhead, commitAhead, staleAhead, standHead, moveLive, lateEdits, sideReader, HOLDER_WAIT_MS } from './source-live/bring-in.js'
40
+ import { blobId } from '@heyquilt/seam/lib/quilts/records.js'
39
41
  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 { makeQuiltWithoutHistory, termsFor } from './session-migrate.js'
43
+ import { forceAtMs, forcedAction } from './force-date.js'
44
+ import { githubRepoOf, installationFor, remoteStage, mainGuarded, rulesetSteps, installSteps, stepsError } from './quilt-remote.js'
45
+ import { syncStoppedWords } from './ui/force-words.js'
46
+ import { historyQuilt, linkedHistory } from './quilt-history.js'
47
+ import { MAX_DIFF_CHARS } from './history.js'
48
+ import { LAYOUT_QUILT, copiedHead, treeIds, treeModes, branchFiles, binariesToUpload, bringsNew, forwardIntoLive, applyChecked, foldBack, writeFold, overlayFor, checkOverlay, copyIntoLive, carryLive, takeGitMeta, restoreGitMeta, squareBase, shareWithPeople, changedGitIds, dropOldCopies, boxOntoHead, writeIntoLive } from './session-layout3.js'
42
49
  import { readMerges } from './merges.js'
43
50
  import { readTasks, updateTask } from './tasks.js'
44
51
  import { addComment } from './task-comments.js'
@@ -63,7 +70,7 @@ import { noteCloudAi, cloudAiWaker } from './relay-ai-vm.js'
63
70
  import { normalizeHosted, hostedIn, newHosted } from './hosted-agents.js'
64
71
  import { ChatArchive } from './chat-archive.js'
65
72
  import { asksRelay, relayAnswer, mergedBranches } from './relay-ask.js'
66
- import { DIFF, markDiff, isDiff, hasEntry, cutOf, notePresent, removePath, gitOf, cachedTree, baseOf, ensureCommit } from './branch-view.js'
73
+ import { DIFF, markDiff, isDiff, hasEntry, cutOf, notePresent, removePath, gitOf, cachedTree, baseOf, ensureCommit, mirrorFor } from './branch-view.js'
67
74
  import { inactiveBranches, mergedByAncestry, mergedByPull, samePatches, deleteOnGithub, pruneNotice, ARCHIVE_KEEP_MS, ANCESTRY_EVERY_MS } from './relay-prune.js'
68
75
  import { githubReader } from './relay-upstream.js'
69
76
  import { RELAY_BY, isRelay } from './branches.js'
@@ -83,6 +90,8 @@ import { clashAction, noteMergeChecks } from './relay-clash.js'
83
90
  import { AGENT_COMMITS } from './github-commit.js'
84
91
  import { repoMoves } from './relay-repo-moves.js'
85
92
  import { UPSTREAM_CHECK_MS, SYNC_EVERY_MS, SYNC_EVERY_GIT_MS, upstreamsOf, noteFolders, applyRepoMove, relayEntry, relayLooks, dueBranches, checkBranch, folderOn, connectedOn, hostedOn, validToken, folderAble, catcherAble, noteHolds, connectedAs, describeRelayCheck, loadBranch, parseRepo, configureGithubBudget, CLASH_IDLE_MS, noteConnection } from './relay-upstream.js'
93
+ import { nowSummary } from './now-summary.js'
94
+ import { squareShort } from '@heyquilt/seam/lib/quilts/errors.js'
86
95
 
87
96
  const ROOM_RE = /^[A-Za-z0-9_-]{1,64}$/
88
97
  const MAX_NAME = 64
@@ -120,6 +129,17 @@ const CLOUD_AI_GONE_MS = 5 * 60 * 1000
120
129
  const BRANCH_SEEN_SAVE_MS = 60 * 60 * 1000
121
130
  // A message to the relay older than this when it reaches the relay gets no answer (answerRelay).
122
131
  const RELAY_ASK_MS = 10 * 60 * 1000
132
+ // The app's reads of a linked quilt (quiltRead): how long a reply waits on Seam before it says
133
+ // "still working" (under the app's 10 s request timeout), how long an answer is kept, and how
134
+ // many lines of a file a blame sends.
135
+ const QUILT_READ_WAIT_MS = 8000
136
+ const QUILT_READ_KEEP_MS = 60 * 1000
137
+ const QUILT_BLAME_MAX_LINES = 20000
138
+ /** A file's path as Seam names it (forward slashes, no leading ./ or /), or '' when it is not one. */
139
+ const quiltFilePath = (p) => {
140
+ const s = typeof p === 'string' ? p.replace(/\\/g, '/').replace(/^(\.\/|\/)+/, '') : ''
141
+ return s && s.length <= 1000 && !s.split('/').includes('..') && !CONTROL_CHARS.test(s) ? s : ''
142
+ }
123
143
  const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
124
144
  const LOGO = path.join(ROOT, 'assets', 'logo.svg')
125
145
  const hash = (s) => crypto.createHash('sha256').update(String(s)).digest()
@@ -222,6 +242,12 @@ function loadedFor (stack) {
222
242
  return frames.slice(0, 2).map((l) => l.replace(/\(?file:\/\/\/?[^)]*\/src\//, '(').replace(/:\d+\)?$/, ')')).join(' < ') || 'unknown'
223
243
  }
224
244
 
245
+ // Seam (16, "How a session becomes a quilt"): a room's layout migration runs under a lease of its
246
+ // own, so two relay machines never move one room at once. Its holder is one attempt (`qm-<id>`),
247
+ // listed here while it runs.
248
+ const MIGRATION_LEASE = 'qm-'
249
+ const migrating = new Set()
250
+
225
251
  class Room {
226
252
  constructor (name, dataDir, cfg, log) {
227
253
  this.name = name
@@ -273,6 +299,7 @@ class Room {
273
299
  })
274
300
  // A room saved before branch documents: its one document becomes the default branch's.
275
301
  this.migrateLegacy()
302
+ this.recoverQuiltMove()
276
303
  this.pruneBranches(Date.now(), { quiet: true }) // at load; the sweep says it in chat
277
304
  this.awareness = new awarenessProtocol.Awareness(this.doc)
278
305
  this.awareness.setLocalState(null)
@@ -353,7 +380,7 @@ class Room {
353
380
  if (this.cloudAiWaker) this.cloudAiWaker.personActive(this, origin).catch(() => {}) // their cloud AI wakes while they work
354
381
  }
355
382
  const msg = updateMessage(update)
356
- for (const ws of this.conns.keys()) if (ws !== origin) send(ws, msg)
383
+ for (const ws of this.conns.keys()) if (ws !== origin && !ws.roomless) send(ws, msg)
357
384
  this.bytes += update.length
358
385
  if (!this.full && this.bytes > cfg.maxRoomBytes) {
359
386
  this.full = true
@@ -370,7 +397,7 @@ class Room {
370
397
  for (const id of removed) ids.delete(id)
371
398
  }
372
399
  const msg = awarenessMessage(this.awareness, changed)
373
- for (const ws of this.conns.keys()) send(ws, msg)
400
+ for (const ws of this.conns.keys()) if (!ws.roomless) send(ws, msg)
374
401
  // Which upstream each branch follows, and where its folders are (relay-upstream.js).
375
402
  if (origin && this.conns.has(origin)) this.noteUpstreams()
376
403
  })
@@ -988,7 +1015,11 @@ class Room {
988
1015
  onBranch (ws, rel) {
989
1016
  const e = ws.branch ? this.store.get(ws.branch) : null
990
1017
  // A diff branch leaves its unchanged files to git: a path it hasn't deleted counts as on it.
991
- return !!e && (hasEntry(e, rel) || (isDiff(e) && !e.gone.has(rel)))
1018
+ if (e && (hasEntry(e, rel) || (isDiff(e) && !e.gone.has(rel)))) return true
1019
+ // At layout 3 the branch's work is in its square: a file new there (live-block.js) is on it too.
1020
+ const lk = this.meta.layout === LAYOUT_QUILT && ws.branch && this.meta.quilt && (this.meta.quilt.live || {})[ws.branch]
1021
+ const sq = isLiveKey(lk) ? this.store.load(lk) : null
1022
+ return !!sq && (sq.files.has(rel) || sq.blobs.has(rel))
992
1023
  }
993
1024
 
994
1025
  /** Whether a new branch here may be a diff one: the relay keeps a git mirror of the session's GitHub repository. */
@@ -1509,7 +1540,8 @@ class Room {
1509
1540
  checkUpstreams ({ force = false, keys = null } = {}) {
1510
1541
  // A look asked for (force) waits for one under way, then has its own: the one under way may not include its branches.
1511
1542
  if (this.upstreamRun) return force ? this.upstreamRun.catch(() => {}).then(() => this.checkUpstreams({ force, keys })) : this.upstreamRun
1512
- if (!this.exists || this.ended || this.destroyed || this.meta.layout !== 2) return Promise.resolve([])
1543
+ this.checkForcedDate()
1544
+ if (!this.exists || this.ended || this.destroyed || this.meta.layout !== 2 || this.meta.syncStopped) return Promise.resolve([])
1513
1545
  this.noteConnections({ keys }) // the connections, about once an hour per branch, with the relay's own looks
1514
1546
  const now = Date.now()
1515
1547
  // With the mirror on: about every minute (git ls-remote, no API quota); webhooks in between (addendum 16.13).
@@ -1824,11 +1856,53 @@ class Room {
1824
1856
  /**
1825
1857
  * What every branch-list message carries: the list and, once the session is a quilt (behind
1826
1858
  * 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).
1859
+ * app and agents can use Seam (Seam's plans/quilt-source-control/16); at layout 3 also `atTip`,
1860
+ * the branches whose squares the move started at main's tip for want of their own commit.
1828
1861
  */
1829
1862
  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 } : {}) }
1863
+ 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.quilt || {}).history ? { history: true } : {}), ...(this.meta.layout === LAYOUT_QUILT ? { layout: LAYOUT_QUILT, live: this.meta.quilt.live || {}, ...(this.meta.quilt.adoption ? { adoption: this.meta.quilt.adoption } : {}), read: this.readTokenState(), ...((this.meta.quilt.basedAtTip || []).length ? { atTip: this.meta.quilt.basedAtTip.map((b) => b.from) } : {}) } : {}) } : null
1864
+ // 16 step 1: the quilt an import of the session's git remote fills while git is still the
1865
+ // authority. Its own field, so nothing that treats the session as a quilt (session-seam.js) moves.
1866
+ const sh = !q && this.meta.quiltShadow && process.env.QUILT_SOURCE_URL ? { id: this.meta.quiltShadow.id, slug: this.meta.quiltShadow.slug || '' } : null
1867
+ // The forced date (16 flag 9, force-date.js): when it is, live sync stopped, or the owner's app is to make the session a quilt now.
1868
+ const at = process.env.QUILT_SOURCE_URL ? forceAtMs() : null
1869
+ const act = this.meta.layout === LAYOUT_QUILT ? null : forcedAction({ at, layout: this.meta.layout, remote: this.hasGitRemote(), stopped: !!this.meta.syncStopped })
1870
+ const force = this.meta.syncStopped ? { stopped: { at: this.meta.syncStopped.at, why: syncStoppedWords(this.meta.syncStopped.at) } } : at != null && this.meta.layout !== LAYOUT_QUILT ? { at: new Date(at).toISOString(), remote: this.hasGitRemote(), ...(act === 'migrate' ? { migrate: true } : {}) } : null
1871
+ // "Make this a quilt" on a session with a git remote, while Seam imports it (quiltMakeWithRemote).
1872
+ const imp = !q && !sh && this.meta.quiltImport && process.env.QUILT_SOURCE_URL ? { slug: this.meta.quiltImport.slug || '', repo: this.meta.quiltImport.repo || '' } : null
1873
+ return { branches: this.branchList(), ...(q ? { quilt: q } : {}), ...(sh ? { shadow: sh } : {}), ...(imp ? { importing: imp } : {}), ...(this.movingNow ? { moving: this.movingNow } : {}), ...(force ? { force } : {}) }
1874
+ }
1875
+
1876
+ /** Whether the session has a git remote (16: a room with an upstream): it becomes a quilt only with its history. */
1877
+ hasGitRemote () { return Object.keys(this.meta.upstreams || {}).length > 0 }
1878
+
1879
+ /**
1880
+ * The forced date (16 flag 9): from QUILT_SOURCE_FORCE_AT on, a session with a git remote that is not
1881
+ * a quilt in its squares has its live file sync stopped, for good (meta.syncStopped): its branch
1882
+ * documents stay as they were at the date, the relay neither takes a member's file edits nor brings
1883
+ * GitHub in or pushes, and every member hears it in plain words. Chat, tasks, the feed and the audit
1884
+ * trail carry on. Nothing in git is touched. A session with no git remote is left to its owner's
1885
+ * next open (branchesFields `force.migrate`): only the owner's sign-in can make it a quilt.
1886
+ */
1887
+ checkForcedDate (now = Date.now()) {
1888
+ if (this.meta.syncStopped || this.meta.layout === LAYOUT_QUILT || this.quiltMoving || !this.exists || this.ended || this.destroyed) return false
1889
+ if (!process.env.QUILT_SOURCE_URL) return false
1890
+ if (forcedAction({ at: forceAtMs(), now, layout: this.meta.layout, remote: this.hasGitRemote() }) !== 'stop') return false
1891
+ const at = new Date(forceAtMs()).toISOString()
1892
+ this.meta.syncStopped = { at, since: new Date(now).toISOString() }
1893
+ this.saveMeta()
1894
+ this.log(`[${this.name}] the forced date (${at}) passed with a git remote and no cut-over: live file sync stopped`)
1895
+ try { this.postChat({ by: 'Quilt', text: syncStoppedWords(at), kind: 'note' }) } catch {}
1896
+ this.broadcastBranches()
1897
+ return true
1898
+ }
1899
+
1900
+ /** A member's write to a branch document after live sync stopped: not kept, and they hear why (at most once a minute a connection). */
1901
+ refusedSyncStopped (ws) {
1902
+ const now = Date.now()
1903
+ if (ws.syncStoppedToldAt && now - ws.syncStoppedToldAt < READ_ONLY_TELL_MS) return
1904
+ ws.syncStoppedToldAt = now
1905
+ send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields() }))
1832
1906
  }
1833
1907
 
1834
1908
  broadcastBranches () {
@@ -2037,7 +2111,7 @@ class Room {
2037
2111
  * app asks before it clears its folder for a switch).
2038
2112
  */
2039
2113
  branchRequest (ws, req) {
2040
- // Seam, behind QUILT_SOURCE_URL (plans/quilt-source-control/16): what making this session a
2114
+ // Seam, behind QUILT_SOURCE_URL (Seam's plans/quilt-source-control/16): what making this session a
2041
2115
  // quilt would do, read only. The owner's (or, in a session without one, any editor's).
2042
2116
  if (req.op === 'quilt-plan') {
2043
2117
  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')
@@ -2046,13 +2120,17 @@ class Room {
2046
2120
  const key = String(req.branch ?? '')
2047
2121
  if (!validBranchKey(key)) throw new Error(`"${key.slice(0, 60)}" isn't a branch name`)
2048
2122
  if (req.op === 'join') {
2049
- // At layout 3 the only documents members join are squares' live documents (quiltSwitch).
2123
+ this.checkForcedDate()
2124
+ // At layout 3 the documents members write are squares' live documents (quiltSwitch). An app
2125
+ // whose folder follows its square in place (quilt-folders) still joins its branch's kept
2126
+ // document, read-only, so it knows the folder's branch and goes back to it with quiltUnswitch.
2050
2127
  const square = this.meta.layout === LAYOUT_QUILT && isLiveKey(key)
2051
- if (!square) this.assertMigrated()
2128
+ const kept = this.meta.layout === LAYOUT_QUILT && !square && (ws.features || []).includes('quilt-folders') && !!this.meta.branches[this.resolveKey(key)]
2129
+ if (!square && !kept) this.assertMigrated()
2052
2130
  // An app from before branch documents names the branch the old document was; else the first real branch takes ∅.
2053
2131
  let adopted = false
2054
- if (!square && validBranchKey(String(req.adopt || ''))) adopted = this.adoptDefault(String(req.adopt))
2055
- if (!square) adopted = this.adoptDefault(key) || adopted
2132
+ if (!square && !kept && validBranchKey(String(req.adopt || ''))) adopted = this.adoptDefault(String(req.adopt))
2133
+ if (!square && !kept) adopted = this.adoptDefault(key) || adopted
2056
2134
  const k = this.resolveKey(key)
2057
2135
  const created = !this.meta.branches[k]
2058
2136
  if (created) {
@@ -2102,6 +2180,16 @@ class Room {
2102
2180
  if (!covers(Y.encodeStateVector(e.doc), want)) throw new Error(`not all of your changes on ${key} have reached the relay yet`)
2103
2181
  return { ok: true, branch: k }
2104
2182
  }
2183
+ // What the relay holds of the app's branch: its state vector, and its deletions (an update with
2184
+ // nothing past the vector). At layout 3 the app's own edits past them are ones the read-only
2185
+ // document refused, and its folder keeps them (Session.unsentPaths).
2186
+ if (req.op === 'state') {
2187
+ const k = this.resolveKey(key)
2188
+ const e = k === ws.branch ? this.store.get(k) : null
2189
+ if (!e) throw new Error(`you are not on ${key}`)
2190
+ const sv = Y.encodeStateVector(e.doc)
2191
+ return { ok: true, branch: k, sv: Buffer.from(sv).toString('base64'), deleted: Buffer.from(Y.encodeStateAsUpdate(e.doc, sv)).toString('base64') }
2192
+ }
2105
2193
  if (req.op === 'remove') {
2106
2194
  const a = this.access.get(ws)
2107
2195
  if (!a || (this.controlled && !a.owner)) throw new Error('only the session owner can remove a branch')
@@ -2113,8 +2201,8 @@ class Room {
2113
2201
  this.removeBranch(k)
2114
2202
  return { ok: true, branch: k, listChanged: true }
2115
2203
  }
2116
- // Quilt source control (src/source-live/live-square.js), behind QUILT_SOURCE: a square's live document's own ops.
2117
- if ((req.op === 'seal' || req.op === 'start') && isLiveKey(key) && process.env.QUILT_SOURCE) {
2204
+ // Quilt source control (src/source-live/live-square.js), behind QUILT_SOURCE, and always once the room is at layout 3: a square's live document's own ops.
2205
+ if ((req.op === 'seal' || req.op === 'start') && isLiveKey(key) && (process.env.QUILT_SOURCE || this.meta.layout === LAYOUT_QUILT)) {
2118
2206
  const k = this.resolveKey(key)
2119
2207
  const e = k === ws.branch ? this.store.get(k) : null
2120
2208
  if (!e) throw new Error(`you are not on ${key}`)
@@ -2151,7 +2239,7 @@ class Room {
2151
2239
  for (const r of this.commitRequests.values()) {
2152
2240
  if (!r || r.state !== 'open') continue
2153
2241
  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.`
2242
+ const text = `Please get this into history: ${(r.files || []).join(', ')}${sq ? ` (square ${squareShort(sq)})` : ''}. ${r.by || 'Someone'} asked for a commit ("${String(r.message || '').slice(0, 200)}") before the session became a quilt: commit it in Seam.`
2155
2243
  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
2244
  else this.postChat({ by: 'Quilt', text, kind: 'note' })
2157
2245
  }
@@ -2167,15 +2255,287 @@ class Room {
2167
2255
  if (!api) return answer({ ok: false, error: 'Seam is not on for this relay, so a session cannot become a quilt here yet' })
2168
2256
  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
2257
  const seam = (this.seamClient || quiltClient)({ api, token: req.token })
2258
+ if (req.op === 'quilt-shadow') {
2259
+ return this.quiltShadowLink(this.access.get(ws), seam, req.quilt ?? null).then((r) => answer({ ok: true, ...r }), (err) => answer({ ok: false, error: err.message }))
2260
+ }
2261
+ if (req.op === 'quilt-unswitch') {
2262
+ return this.quiltUnswitch(this.access.get(ws), seam, { carried: req.carried || [] }).then((r) => answer({ ok: true, ...r }), (err) => answer({ ok: false, error: err.message, ...(err.open ? { open: err.open } : {}) }))
2263
+ }
2170
2264
  if (req.op === 'quilt-switch') {
2171
2265
  return this.quiltSwitch(this.access.get(ws), seam).then((r) => answer({ ok: true, ...r }), (err) => answer({ ok: false, error: err.message }))
2172
2266
  }
2267
+ if (req.op === 'quilt-make') {
2268
+ return this.quiltMake(this.access.get(ws), seam).then((r) => answer({ ok: true, quilt: r }), (err) => answer({ ok: false, error: err.message, ...(err.quilt_id ? { quilt_id: err.quilt_id } : {}), ...(err.steps ? { steps: err.steps } : {}) }))
2269
+ }
2270
+ if (req.op === 'quilt-next') {
2271
+ return this.quiltNext(this.access.get(ws), seam, req.branch).then((r) => answer({ ok: true, ...r }), (err) => answer({ ok: false, error: err.message }))
2272
+ }
2273
+ if (req.op === 'quilt-open') {
2274
+ return this.quiltOpen(this.access.get(ws), seam, req.branch ?? ws.branch).then((r) => answer({ ok: true, ...r }), (err) => answer({ ok: false, error: err.message }))
2275
+ }
2276
+ if (req.op === 'quilt-catch-up') {
2277
+ // An app that asks to bring main in follows a square whose base moves (0.3.133 on): its folder is no reason to wait (outdatedOn).
2278
+ if (!this.movesApps) this.movesApps = new Set()
2279
+ this.movesApps.add(appOf(ws, this.names.get(ws)))
2280
+ return this.quiltCatchUp(this.access.get(ws), seam, req.branch ?? ws.branch, { by: this.names.get(ws) || '' }).then((r) => answer({ ok: true, ...r }), (err) => answer({ ok: false, error: err.message }))
2281
+ }
2173
2282
  this.quiltMigrate(this.access.get(ws), seam).then(
2174
2283
  (r) => answer({ ok: true, quilt: r }),
2175
2284
  (err) => answer({ ok: false, error: err.message, ...(err.quilt_id ? { quilt_id: err.quilt_id } : {}) })
2176
2285
  )
2177
2286
  }
2178
2287
 
2288
+ /**
2289
+ * 16 step 1 ("Shadow"), behind QUILT_SOURCE_URL: links the session to the quilt an import of its
2290
+ * git remote fills (`quilt`, its slug or id; null unlinks), as the owner with `seam` acting as
2291
+ * them. Nothing else changes: the session stays git-mode, agents keep their git path, and the
2292
+ * link only lets quilt_history (and the app) read the quilt's log. A read-only quilt token is
2293
+ * made for the room (`meta.quiltShadow.read`), so hosted agents read the log without anyone's
2294
+ * sign-in; making it needs admin on the quilt, which is how the owner proves the quilt is theirs.
2295
+ * The with-history migration (quiltMigrate) later turns this link into the session's quilt.
2296
+ */
2297
+ async quiltShadowLink (a, seam, quilt) {
2298
+ const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
2299
+ if (no) throw new Error(no.replace('see what making it a quilt would do', 'link it to a quilt'))
2300
+ if (this.meta.quilt_id) {
2301
+ const slug = (this.meta.quilt && this.meta.quilt.slug) || ''
2302
+ if (!quilt || (quilt !== this.meta.quilt_id && quilt !== slug && !String(quilt).endsWith(`/${slug}`))) throw new Error(`this session is already the quilt ${slug || this.meta.quilt_id}`)
2303
+ // Linking the session to its own quilt again makes the room's read token new when it is gone, ending or refused.
2304
+ const r = await this.freshReadToken(seam, this.meta.quilt_id, this.meta.quilt.read)
2305
+ if (r.renewed || this.meta.quilt.readRefusedAt) this.takeReadToken(r.read)
2306
+ return { shadow: { id: this.meta.quilt_id, slug }, read: { renewed: r.renewed, ...(r.read.expires_at ? { until: new Date(r.read.expires_at).toISOString() } : {}) } }
2307
+ }
2308
+ if (this.quiltMoving) throw new Error('this session is already being made a quilt')
2309
+ const was = this.meta.quiltShadow || null
2310
+ if (quilt === null || quilt === '') {
2311
+ if (!was) return { shadow: null }
2312
+ if (was.read && was.read.id && seam.revokeToken) await seam.revokeToken(was.id, was.read.id).catch(() => {})
2313
+ delete this.meta.quiltShadow
2314
+ this.saveMeta()
2315
+ this.broadcastBranches()
2316
+ return { shadow: null, was: { id: was.id, slug: was.slug } }
2317
+ }
2318
+ if (typeof quilt !== 'string' || quilt.length > 200) throw new Error('name the quilt to link (its slug or id)')
2319
+ this.quiltMoving = true
2320
+ try {
2321
+ const info = await seam.quilt(quilt)
2322
+ if (!info || !info.id) throw new Error(`Seam has no quilt ${quilt}`)
2323
+ if (!(info.lines || []).some((l) => l && l.name === 'main' && l.tip)) throw new Error(`${info.slug || quilt} has nothing on main yet: import the session's git remote into it first (seam import git ... --follow)`)
2324
+ let read
2325
+ try {
2326
+ const t = await seam.createToken(info.id, { name: `Quilt session ${this.name}`.slice(0, 100), level: 'read', expires_in_days: 365 })
2327
+ read = { token: t.token, id: t.id, ...(t.expires_at ? { expires_at: t.expires_at } : {}) }
2328
+ } catch (err) {
2329
+ throw new Error(err.status === 403 ? `only an admin of ${info.slug || quilt} can link a session to it` : `Seam did not make a read token for the session: ${err.message}`)
2330
+ }
2331
+ if (was && was.read && was.read.id && was.id !== info.id && seam.revokeToken) await seam.revokeToken(was.id, was.read.id).catch(() => {})
2332
+ this.meta.quiltShadow = { id: info.id, slug: info.slug || '', at: new Date().toISOString(), by: (a && (a.sub || a.key || a.name)) || '', read }
2333
+ this.saveMeta()
2334
+ this.broadcastBranches()
2335
+ return { shadow: { id: info.id, slug: info.slug || '' } }
2336
+ } finally {
2337
+ this.quiltMoving = false
2338
+ }
2339
+ }
2340
+
2341
+ /** A Seam client with the room's read token (shadow link or quilt), or null when it has none. */
2342
+ quiltReadClient () {
2343
+ const api = process.env.QUILT_SOURCE_URL
2344
+ const read = this.meta.quilt_id ? (this.meta.quilt || {}).read : (this.meta.quiltShadow || {}).read
2345
+ if (!api || !read || !read.token) return null
2346
+ if (read.expires_at && read.expires_at <= Date.now()) return null
2347
+ // No retries: Seam down falls back to the session's own record at once, with why.
2348
+ return (this.seamClient || quiltClient)({ api, token: read.token, retry: false })
2349
+ }
2350
+
2351
+ /**
2352
+ * The room's read token for quilt `q` (`read`, the one it has), as an admin `seam` checks it at a
2353
+ * move: kept while it has more than READ_TOKEN_RENEW_MS left and (`probe`) Seam still takes it;
2354
+ * else a new one is made and the old one revoked, so every member's folder can read its square.
2355
+ * { read, renewed: false | 'missing' | 'expiring' | 'refused' }; throws when Seam makes none.
2356
+ */
2357
+ async freshReadToken (seam, q, read, { probe = true } = {}) {
2358
+ let why = !read || !read.token ? 'missing' : read.expires_at && read.expires_at - Date.now() < READ_TOKEN_RENEW_MS ? 'expiring' : ''
2359
+ if (!why && probe) {
2360
+ try { await (this.seamClient || quiltClient)({ api: process.env.QUILT_SOURCE_URL, token: read.token, retry: false }).quilt(q) } catch (err) {
2361
+ // Seam answers a token it no longer takes with 401, or 404 for a quilt it can't see (revoked).
2362
+ if (err.status === 401 || err.status === 403 || err.status === 404) why = 'refused'
2363
+ }
2364
+ }
2365
+ if (!why) return { read, renewed: false }
2366
+ let t
2367
+ try { t = await seam.createToken(q, { name: `Quilt session ${this.name}`.slice(0, 100), level: 'read', expires_in_days: 365 }) } catch (err) {
2368
+ throw new Error(`the session's read token for its quilt is ${why === 'missing' ? 'missing' : why === 'expiring' ? 'about to end' : 'no longer taken by Seam'}, and Seam did not make a new one${err.status === 403 ? ' (only an admin of the quilt can)' : `: ${err.message}`}`)
2369
+ }
2370
+ if (read && read.id && seam.revokeToken) await seam.revokeToken(q, read.id).catch(() => {})
2371
+ return { read: { token: t.token, id: t.id, ...(t.expires_at ? { expires_at: t.expires_at } : {}) }, renewed: why }
2372
+ }
2373
+
2374
+ /**
2375
+ * What apps hear of the room's read token at layout 3 (never the token): when it ends, and
2376
+ * `renew` once it is missing, ends within READ_TOKEN_RENEW_MS or Seam refused it. The owner's app
2377
+ * renews it then, as the owner (Session.renewReadToken): the relay holds no credential that can.
2378
+ * Only an admin of the quilt makes a quilt token; the relay has the read token itself (read only)
2379
+ * and its accounts-API secret (not a Seam sign-in), and keeping the owner's sign-in on the relay
2380
+ * would let anyone with its disk act as them.
2381
+ */
2382
+ readTokenState () {
2383
+ const read = (this.meta.quilt || {}).read
2384
+ const ends = read && read.expires_at ? Number(read.expires_at) : null
2385
+ const renew = !read || !read.token || !!this.meta.quilt.readRefusedAt || (ends !== null && ends - Date.now() < READ_TOKEN_RENEW_MS)
2386
+ return { ...(ends ? { ends } : {}), ...(renew ? { renew: true } : {}) }
2387
+ }
2388
+
2389
+ /** The room's read token is `read` from now on (made again by freshReadToken): kept, and apps hear it needs nothing. */
2390
+ takeReadToken (read) {
2391
+ this.meta.quilt.read = read
2392
+ delete this.meta.quilt.readRefusedAt
2393
+ this.saveMeta()
2394
+ this.broadcastBranches()
2395
+ }
2396
+
2397
+ /** Seam refused the room's read token (or it is gone): apps hear it, so the owner's renews it at once. */
2398
+ readTokenRefused () {
2399
+ if (!this.meta.quilt || this.meta.quilt.readRefusedAt) return
2400
+ this.meta.quilt.readRefusedAt = Date.now()
2401
+ this.saveMeta()
2402
+ this.broadcastBranches()
2403
+ }
2404
+
2405
+ /** The words for a room whose read token is gone or refused: what its owner does about it. */
2406
+ noReadToken () {
2407
+ const name = (this.meta.quilt && this.meta.quilt.slug) || this.meta.quilt_id || 'its quilt'
2408
+ return `the session's read token for ${name} is missing or has ended: its owner makes a new one with quilt session migrate --shadow ${name}`
2409
+ }
2410
+
2411
+ /**
2412
+ * One read of the room's quilt for a member's block (live-block.js): `call` square, tree, blobs
2413
+ * or diff, always of this room's own quilt, with the room's read token. Only at layout 3.
2414
+ */
2415
+ async quiltBlockRead ({ call, square, rev, paths, ids, from, to, file, format } = {}) {
2416
+ if (this.meta.layout !== LAYOUT_QUILT || !this.meta.quilt_id) throw new Error('this session\'s files are not in squares')
2417
+ const seam = this.quiltReadClient()
2418
+ if (!seam) { this.readTokenRefused(); throw new Error(this.noReadToken()) }
2419
+ const q = this.meta.quilt_id
2420
+ const list = (x, n) => Array.isArray(x) ? x.slice(0, n).map(String) : undefined
2421
+ const read = async () => {
2422
+ if (call === 'square') return { square: await seam.square(q, String(square || '')) }
2423
+ if (call === 'tree') return { tree: await seam.tree(q, String(rev || ''), list(paths, 100)) }
2424
+ if (call === 'blobs') {
2425
+ const want = (list(ids, 200) || []).filter((x) => /^[0-9a-f]{64}$/.test(x))
2426
+ return { blobs: want.length ? (await seam.blobs(q, want)).blobs : {} }
2427
+ }
2428
+ if (call === 'diff') return { diff: await seam.diff(q, { from: String(from || ''), to: String(to || ''), paths: list(paths, 100), format: 'names' }) }
2429
+ // A conflicted file has no bytes of its own: a folder writes it with its markers (live-block.js headFile).
2430
+ if (call === 'cat') return { cat: await seam.cat(q, String(rev || ''), String(file || ''), { format: format === 'git' ? 'git' : 'diff' }) }
2431
+ throw new Error('unknown read of the quilt')
2432
+ }
2433
+ // A token Seam no longer takes (revoked): 401, or a quilt it can't see; said with what the owner does.
2434
+ try { return await read() } catch (err) {
2435
+ if (err.status === 401 || (err.status === 404 && /no such quilt/i.test(String(err.message)))) { this.readTokenRefused(); throw new Error(this.noReadToken()) }
2436
+ throw err
2437
+ }
2438
+ }
2439
+
2440
+ /** The quilt this room's chronology reads (quilt-history.js historyQuilt) and a client to read it with. */
2441
+ quiltHistoryLink () {
2442
+ const quilt = historyQuilt(this.meta)
2443
+ return quilt ? { quilt, seam: this.quiltReadClient() } : null
2444
+ }
2445
+
2446
+ /** The quilt's log for `query` (path, by, since, task, limit, with_diff), as chronology entries; null when the room has no link. */
2447
+ async quiltLog (query = {}) {
2448
+ const link = this.quiltHistoryLink()
2449
+ if (!link) return { ok: true, quilt: null, entries: [] }
2450
+ const r = await linkedHistory({ seam: link.seam, quilt: link.quilt, entries: [], query: { path: query.path || '', by: query.by, since: typeof query.since === 'number' ? query.since : undefined, task: query.task, limit: Math.min(Number(query.limit) || 30, 200), withDiff: !!query.with_diff } })
2451
+ return { ok: true, quilt: link.quilt, entries: r.list, ...(r.note ? { note: r.note } : {}) }
2452
+ }
2453
+
2454
+ /**
2455
+ * One read of the room's quilt for the app (blame, search, a patch), with the room's read token:
2456
+ * one request to Seam per `key` at a time, its answer kept a minute. Answers within `wait` ms,
2457
+ * else { working: true } and the app asks again: a first blame of a file is worked out by Seam
2458
+ * (202 "deriving"), which the client waits on at Seam's own pace. { quilt: null } when not linked.
2459
+ */
2460
+ quiltRead (key, read, { wait = this.quiltReadWaitMs ?? QUILT_READ_WAIT_MS } = {}) {
2461
+ const link = this.quiltHistoryLink()
2462
+ if (!link) return Promise.resolve({ ok: true, quilt: null })
2463
+ if (!link.seam) return Promise.resolve({ ok: false, error: `the session has no read token for ${link.quilt.slug || link.quilt.id}: link it again` })
2464
+ const reads = this.quiltReads || (this.quiltReads = new Map())
2465
+ for (const [k, r] of reads) if (r.done && Date.now() - r.done > QUILT_READ_KEEP_MS) reads.delete(k)
2466
+ const k = `${link.quilt.id}\0${key}`
2467
+ let r = reads.get(k)
2468
+ if (!r) {
2469
+ r = { done: 0, answer: null }
2470
+ r.promise = read(link.seam, link.quilt.id)
2471
+ .then((v) => ({ ok: true, quilt: link.quilt, ...v }), (err) => ({ ok: false, error: `Seam: ${String(err.message || err).slice(0, 300)}` }))
2472
+ .then((ans) => { r.answer = ans; r.done = Date.now(); if (!ans.ok && reads.get(k) === r) reads.delete(k); return ans })
2473
+ reads.set(k, r)
2474
+ if (reads.size > 64) reads.delete(reads.keys().next().value)
2475
+ }
2476
+ if (r.done) return Promise.resolve(r.answer)
2477
+ return Promise.race([r.promise, new Promise((resolve) => { const t = setTimeout(() => resolve({ ok: true, quilt: link.quilt, working: true }), wait); t.unref?.() })])
2478
+ }
2479
+
2480
+ /**
2481
+ * Who wrote each line of `path` on main, from Seam's blame: { blame: { commit, who: [{ commit,
2482
+ * short, by, at, patch }], lines: [[text, index in who or -1]] } }, or { missing: true } when the
2483
+ * file is not on main (all of it is live work).
2484
+ */
2485
+ quiltBlame (p) {
2486
+ const file = quiltFilePath(p)
2487
+ if (!file) return Promise.reject(new Error('name a file'))
2488
+ return this.quiltRead(`blame\0${file}`, async (seam, q) => {
2489
+ let b
2490
+ try { b = await seam.blame(q, 'main', file) } catch (err) { if (err.status === 404) return { missing: true }; throw err }
2491
+ const who = []
2492
+ const at = new Map()
2493
+ const lines = (b.lines || []).slice(0, QUILT_BLAME_MAX_LINES).map((l) => {
2494
+ if (!l.commit) return [String(l.text ?? ''), -1]
2495
+ if (!at.has(l.commit)) { at.set(l.commit, who.length); who.push({ commit: l.commit, short: l.short || l.commit.slice(0, 12), by: String(l.author || ''), at: Number(l.at) || 0, ...(l.patch && l.patch.patch_no != null ? { patch: l.patch.patch_no } : {}) }) }
2496
+ return [String(l.text ?? ''), at.get(l.commit)]
2497
+ })
2498
+ return { blame: { commit: b.commit || null, who, lines } }
2499
+ })
2500
+ }
2501
+
2502
+ /** Code search over main (Seam's /search): { search: { commit, counts, more, hits: [{ path, line, text, symbol }] } }. */
2503
+ quiltSearch (text) {
2504
+ const q = typeof text === 'string' ? text.trim() : ''
2505
+ if (!q || q.length > 300) return Promise.reject(new Error('search for some text (300 characters at most)'))
2506
+ return this.quiltRead(`search\0${q}`, async (seam, id) => {
2507
+ const r = await seam.searchCode(id, { q, rev: 'main', limit: 100 })
2508
+ const hits = ((r && r.results) || []).filter((x) => x && typeof x.path === 'string').map((x) => ({
2509
+ path: x.path,
2510
+ ...(x.line ? { line: Number(x.line) } : {}),
2511
+ ...(x.kind === 'content' ? { text: String(x.text || '').slice(0, 500) } : {}),
2512
+ ...(x.kind === 'symbol' && x.symbol ? { symbol: `${x.symbol.kind || ''} ${x.symbol.name || ''}`.trim() } : {})
2513
+ }))
2514
+ return { search: { commit: (r && r.commit) || null, counts: (r && r.counts) || { files: 0, matches: 0 }, more: !!(r && (r.cursor || r.more)), hits } }
2515
+ })
2516
+ }
2517
+
2518
+ /** One patch (a commit of the log) for a file's History: { patch: { commit, files: [{ path, added, removed }], diff } }, the diff of `path` only. */
2519
+ quiltShow (commit, p) {
2520
+ if (typeof commit !== 'string' || !/^[0-9a-f]{12,64}$/.test(commit)) return Promise.reject(new Error('name a commit'))
2521
+ const file = quiltFilePath(p)
2522
+ return this.quiltRead(`show\0${commit}\0${file}`, async (seam, id) => {
2523
+ const s = await seam.show(id, commit, { format: 'unified' })
2524
+ const files = ((s && s.diff && s.diff.files) || []).map((f) => {
2525
+ let added = 0
2526
+ let removed = 0
2527
+ for (const l of String(f.patch || '').split('\n')) {
2528
+ if (l.startsWith('+') && !l.startsWith('+++')) added++
2529
+ else if (l.startsWith('-') && !l.startsWith('---')) removed++
2530
+ }
2531
+ return { path: String(f.path || ''), added, removed, ...(f.binary ? { binary: true } : {}), patch: String(f.patch || '') }
2532
+ })
2533
+ const mine = files.find((f) => f.path === file)
2534
+ const diff = mine ? mine.patch.split('\n').filter((l) => !l.startsWith('--- ') && !l.startsWith('+++ ')).join('\n') : ''
2535
+ return { patch: { commit, files: files.map(({ patch, ...f }) => f), diff: diff.length > MAX_DIFF_CHARS ? `${diff.slice(0, MAX_DIFF_CHARS)}\n… (diff truncated)` : diff } }
2536
+ })
2537
+ }
2538
+
2179
2539
  /** The dry run of making this session a quilt, for someone with access `a` (session-quilt.js). */
2180
2540
  quiltPlan (a, tip = null) {
2181
2541
  const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
@@ -2216,7 +2576,7 @@ class Room {
2216
2576
  }
2217
2577
 
2218
2578
  /**
2219
- * Makes this session a quilt (plans/quilt-source-control/16, "Without history"), behind
2579
+ * Makes this session a quilt (Seam's plans/quilt-source-control/16, "Without history"), behind
2220
2580
  * QUILT_SOURCE_URL: the owner's (as quiltPlan), with `seam` a Seam client acting as them.
2221
2581
  * The room is linked (`meta.quilt_id`, `meta.quilt`) only when every step worked, so a
2222
2582
  * failure leaves it a git-mode session as before. A room with a git remote goes with history
@@ -2227,17 +2587,18 @@ class Room {
2227
2587
  if (no) throw new Error(no.replace('see what making it a quilt would do', 'make it a quilt'))
2228
2588
  this.assertMigrated()
2229
2589
  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 {
2590
+ if (this.meta.quiltShadow) return this.quiltMigrateWithHistory(a, seam, { date })
2591
+ if (Object.keys(this.meta.upstreams || {}).length) throw new Error('this session has a git remote: it becomes a quilt with its history. Import the remote into a quilt with follow, link the session to it (quilt session migrate --shadow <quilt>), cut the import over, then migrate (docs/seam-cutover.md in the Seam repository)')
2592
+ return this.withMigrationLease(async () => {
2593
+ // Texts and small binaries (base64 in the document) go into the quilt; large files in storage are left out.
2234
2594
  const texts = (key) => {
2235
2595
  const e = this.store.load(key)
2236
- const files = {}; const big = []
2237
- if (!e) return { files, big, merges: [] }
2596
+ const files = {}; const binaries = {}; const big = []; const modes = {}
2597
+ if (!e) return { files, binaries, modes, big, merges: [] }
2238
2598
  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')) : [] }
2599
+ for (const [p, x] of e.blobs) if (x && !(p in files)) { if (typeof x.data === 'string') binaries[p] = x.data; else big.push(p) }
2600
+ if (e.modes) for (const [p, m] of e.modes) if (m === 'x') modes[p] = 'x'
2601
+ return { files, binaries, modes, big, merges: e.doc ? readMerges(e.doc.getMap('merges')) : [] }
2241
2602
  }
2242
2603
  const def = texts(this.defaultKey)
2243
2604
  const leftOut = new Set(def.big)
@@ -2246,23 +2607,269 @@ class Room {
2246
2607
  if (key === this.defaultKey || isLiveKey(key)) continue
2247
2608
  const t = texts(key)
2248
2609
  for (const p of t.big) leftOut.add(`${key}:${p}`)
2249
- branches.push({ key, files: t.files, merges: t.merges })
2610
+ branches.push({ key, files: t.files, binaries: t.binaries, merges: t.merges })
2250
2611
  }
2251
- const r = await makeQuiltWithoutHistory({ seam, room: this.name, defaultFiles: def.files, defaultMerges: def.merges, branches, leftOut: [...leftOut], date })
2612
+ const r = await makeQuiltWithoutHistory({ seam, room: this.name, defaultFiles: def.files, defaultBinaries: def.binaries, defaultModes: def.modes, defaultMerges: def.merges, branches, leftOut: [...leftOut], date })
2252
2613
  this.meta.quilt_id = r.quilt_id
2253
2614
  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
2615
  // Open merges now live in Seam as conflict terms; the ones with no text to carry are named.
2255
2616
  if (r.terms.length || r.merges_left.length) this.meta.quilt.merges = { terms: r.terms, left: r.merges_left }
2256
2617
  this.meta.quilt.tasks = this.quiltTasks(this.meta.quilt.squares)
2257
2618
  this.quiltCommitRequests(this.meta.quilt.squares)
2619
+ // A read token for the room, so hosted agents' quilt_history reads the quilt's log (best effort: without it they read the session's own record).
2620
+ if (seam.createToken) {
2621
+ try { const t = await seam.createToken(r.quilt_id, { name: `Quilt session ${this.name}`.slice(0, 100), level: 'read', expires_in_days: 365 }); if (t && t.token) this.meta.quilt.read = { token: t.token, id: t.id, ...(t.expires_at ? { expires_at: t.expires_at } : {}) } } catch {}
2622
+ }
2258
2623
  this.saveMeta()
2259
2624
  this.broadcastBranches()
2260
2625
  return { ...r, tasks: this.meta.quilt.tasks }
2626
+ }, seam)
2627
+ }
2628
+
2629
+ /**
2630
+ * What a move into squares made so far, saved as it goes (`meta.quiltMove`): the squares it opened
2631
+ * in Seam, the claims it moved there, and the square documents it made here. A relay that stops
2632
+ * part way (a deploy, a crash) finds it at load (recoverQuiltMove); done or undone, it goes.
2633
+ */
2634
+ startQuiltMove (quilt) {
2635
+ this.meta.quiltMove = { quilt, at: Date.now(), opened: [], claimed: [], made: [], ...(this.migrationLease ? { lease: this.migrationLease } : {}) }
2636
+ this.saveMeta()
2637
+ return this.meta.quiltMove
2638
+ }
2639
+
2640
+ endQuiltMove () {
2641
+ if (!this.meta.quiltMove) return
2642
+ delete this.meta.quiltMove
2643
+ this.saveMeta()
2644
+ }
2645
+
2646
+ /**
2647
+ * At load: a move that never finished (layout 3 is written last, with the record's removal) is
2648
+ * undone here. The square documents it made leave, and what it opened in Seam is kept in
2649
+ * `meta.quiltLeftOver` for the owner's next move to abandon (only the owner's sign-in can). The
2650
+ * branch documents were never touched, so the session is as it was before the move.
2651
+ */
2652
+ recoverQuiltMove () {
2653
+ const mv = this.meta.quiltMove
2654
+ if (!mv) return
2655
+ delete this.meta.quiltMove
2656
+ if (this.meta.layout !== LAYOUT_QUILT) {
2657
+ const live = new Set(Object.values((this.meta.quilt && this.meta.quilt.live) || {}))
2658
+ for (const lk of mv.made || []) if (isLiveKey(lk) && !live.has(lk) && this.meta.branches[lk]) this.removeBranch(lk, { save: false })
2659
+ const left = this.meta.quiltLeftOver || { quilt: mv.quilt, squares: [], claims: [] }
2660
+ if (left.quilt === mv.quilt) {
2661
+ left.squares = [...new Set([...left.squares, ...(mv.opened || [])])]
2662
+ left.claims = [...left.claims, ...(mv.claimed || [])]
2663
+ if (mv.lease) left.lease = mv.lease
2664
+ this.meta.quiltLeftOver = left
2665
+ }
2666
+ this.log(`[${this.name}] a move into squares stopped part way (the relay stopped): undone, the session is as it was; ${(mv.opened || []).length} square(s) it opened are abandoned at the next move`)
2667
+ }
2668
+ this.saveMeta()
2669
+ }
2670
+
2671
+ /** What a stopped move opened in Seam (recoverQuiltMove) is let go of, as the owner moving the session now. */
2672
+ async letGoOfStoppedMove (seam) {
2673
+ const left = this.meta.quiltLeftOver
2674
+ if (!left) return
2675
+ for (const c of left.claims || []) if (seam.unclaim) await seam.unclaim(left.quilt, c.square, c.id).catch(() => {})
2676
+ for (const id of left.squares || []) if (seam.abandon) await seam.abandon(left.quilt, id, { reason: 'a move of the session into the quilt stopped part way' }).catch(() => {})
2677
+ delete this.meta.quiltLeftOver
2678
+ this.saveMeta()
2679
+ }
2680
+
2681
+ /**
2682
+ * A move goes on only while the room is still this machine's: the cluster can hand it to another
2683
+ * machine (or the relay can stop) while the move waits on Seam, and this copy saves nothing after
2684
+ * that. Throws, so the move undoes what it made in Seam itself; the other machine undoes the rest
2685
+ * from the move's record (recoverQuiltMove).
2686
+ */
2687
+ stillHere () {
2688
+ if (this.released || this.ended) throw new Error('the session left this relay machine during the move; move it again')
2689
+ if (this.moveLeaseLost) throw new Error('another relay machine took this session\'s move lease during the move; move it again')
2690
+ }
2691
+
2692
+ /**
2693
+ * Which git blob ids each of `paths` had in the default branch's history (the relay's git mirror,
2694
+ * up to the commit the session last knew main at): Map path -> Set, or null when the mirror can't
2695
+ * say (no git sync on this relay, no mirror, the commit out of reach).
2696
+ */
2697
+ /**
2698
+ * Main's earlier versions of `file` from the history of `rev` in Seam (the move's square head), with
2699
+ * the head's own text (`headId`): { head, versions } for boxOntoHead, or null when they can't be read.
2700
+ */
2701
+ async versionsOnLine (seam, q, rev, file, headId) {
2702
+ if (!seam.log || !seam.blobs) return null
2703
+ const log = await seam.log(q, { rev, paths: [file], limit: 50 })
2704
+ const ids = new Set([headId])
2705
+ for (const c of (log && log.commits) || []) {
2706
+ if (!c || !c.id) continue
2707
+ const t = await seam.tree(q, c.id, [file])
2708
+ for (const e of (t && t.entries) || []) if (e.path === file && e.blob) ids.add(e.blob)
2709
+ }
2710
+ const { blobs } = await seam.blobs(q, [...ids])
2711
+ const text = (id) => blobs && blobs[id] ? Buffer.from(blobs[id], 'base64').toString('utf8') : null
2712
+ const head = text(headId)
2713
+ return head === null ? null : { head, versions: [...ids].map(text).filter((x) => typeof x === 'string') }
2714
+ }
2715
+
2716
+ async mainHistoryOf (paths) {
2717
+ if (!paths.length) return new Map()
2718
+ const up = (this.meta.upstreams || {})[this.defaultKey]
2719
+ if (!up || !/^[0-9a-f]{40,64}$/.test(String(up.sha || '')) || !this.mirrors || !this.cfg.gitSync) return null
2720
+ let m = null
2721
+ try { m = await mirrorFor(this, this.defaultKey) } catch {}
2722
+ if (!m || !(await ensureCommit(m.dir, up.sha, { token: m.token }))) return null
2723
+ const out = new Map()
2724
+ for (let i = 0; i < paths.length; i += 500) {
2725
+ const r = await runGit(m.dir, ['log', '--format=', '--raw', '--no-abbrev', '--no-renames', '--root', '-m', up.sha, '--', ...paths.slice(i, i + 500)])
2726
+ if (!r.ok) return null
2727
+ for (const line of r.out.split('\n')) {
2728
+ const x = /^:\d+ \d+ ([0-9a-f]{40,64}) ([0-9a-f]{40,64}) \w+\t(.+)$/.exec(line)
2729
+ if (!x) continue
2730
+ if (!out.has(x[3])) out.set(x[3], new Set())
2731
+ out.get(x[3]).add(x[1]).add(x[2])
2732
+ }
2733
+ }
2734
+ return out
2735
+ }
2736
+
2737
+ /**
2738
+ * One layout move of this room at a time, here and on every relay machine (a lease per room,
2739
+ * relay-lease.js). With `seam` (the owner's), what a move stopped part way left in Seam
2740
+ * (recoverQuiltMove) is let go of first.
2741
+ */
2742
+ async withMigrationLease (fn, seam = null, kind = 'in') {
2743
+ if (this.quiltMoving) throw new Error('this session is already being moved')
2744
+ this.quiltMoving = true
2745
+ let done
2746
+ this.quiltMoved = new Promise((resolve) => { done = resolve })
2747
+ const keeper = this.leases
2748
+ const lease = keeper ? { repo: `relay.quilt/layout3/${crypto.createHash('sha256').update(this.name).digest('hex').slice(0, 40)}`, branch: 'migrate', room: `${MIGRATION_LEASE}${crypto.randomBytes(8).toString('hex')}` } : null
2749
+ let renew = null
2750
+ try {
2751
+ if (lease) {
2752
+ migrating.add(lease.room)
2753
+ let got = await keeper.ensure(lease)
2754
+ // The lease of a move that stopped with its relay (recoverQuiltMove): this relay lets go of it
2755
+ // when it still holds it, else it lapses within LEASE_TTL_MS of its last renewal.
2756
+ const stopped = this.meta.quiltLeftOver && this.meta.quiltLeftOver.lease
2757
+ if (!got.ok && stopped && got.room === stopped && await keeper.release({ ...lease, room: stopped })) got = await keeper.ensure(lease)
2758
+ if (!got.ok && stopped && got.room === stopped) throw new Error('a move of this session stopped part way when the relay restarted, and still holds its lease for up to two minutes; try again then')
2759
+ if (!got.ok) throw new Error(got.error ? `the relay could not take this session's migration lease (${got.error}); try again` : 'another relay machine is moving this session right now; try again in a few minutes')
2760
+ this.migrationLease = lease.room
2761
+ // The move keeps its own lease while it runs (minutes for a big session), whether or not this
2762
+ // relay renews live-sync leases; a lease it loses (another machine took it) stops it (stillHere).
2763
+ renew = setInterval(() => {
2764
+ keeper.ensure(lease).then((r) => { if (!r.ok && !r.error) this.moveLeaseLost = true }, () => {})
2765
+ }, this.cfg.leaseHeartbeatMs || LEASE_HEARTBEAT_MS)
2766
+ renew.unref?.()
2767
+ }
2768
+ // Every member hears the move is running (branchesFields `moving`): the app says so while it lasts.
2769
+ this.movingNow = { kind, since: Date.now() }
2770
+ this.broadcastBranches()
2771
+ if (seam) await this.letGoOfStoppedMove(seam)
2772
+ return await fn()
2261
2773
  } finally {
2774
+ clearInterval(renew)
2775
+ if (this.movingNow) { this.movingNow = null; if (!this.destroyed) this.broadcastBranches() }
2776
+ if (lease) { migrating.delete(lease.room); await keeper.release(lease).catch(() => {}) }
2777
+ this.moveLeaseLost = false
2778
+ this.migrationLease = null
2262
2779
  this.quiltMoving = false
2780
+ done()
2263
2781
  }
2264
2782
  }
2265
2783
 
2784
+ /**
2785
+ * 16 "With history", in the cut-over sitting: a session with a git remote becomes the quilt its
2786
+ * import filled (the shadow link, quiltShadowLink), no new quilt. Only once the import is cut
2787
+ * over (main no longer frozen for the follow), because from now on Seam, not git, is the
2788
+ * authority. Each other branch document becomes a square based at its ref's imported commit
2789
+ * (`git:<sha>`), a detached `@sha` document at that commit, else at main's tip; open merges
2790
+ * become conflict terms in their square's first commit; then the layout-3 switch runs in the
2791
+ * same lease (the default document's difference from the imported tip is the adoption square).
2792
+ * If the switch fails, the link is undone too: the session is a git-mode session that shadows
2793
+ * the quilt, as before.
2794
+ */
2795
+ async quiltMigrateWithHistory (a, seam, { date = new Date() } = {}) {
2796
+ // After the forced date stopped live sync, the branch documents are older than git: an adoption square made from
2797
+ // them would put back what the team committed since. Refused until the cut-over that skips them is built (NOT-DONE.md).
2798
+ if (this.meta.syncStopped) throw new Error(`live sync stopped on ${String(this.meta.syncStopped.at).slice(0, 10)}, so this session's branches are older than its git remote: moving it into its quilt with them would undo work committed since. That move is not built yet`)
2799
+ return this.withMigrationLease(async () => {
2800
+ const sh = this.meta.quiltShadow
2801
+ if (!sh) throw new Error('this session is no longer linked to a quilt')
2802
+ const info = await seam.quilt(sh.id)
2803
+ const main = ((info && info.lines) || []).find((l) => l && l.name === 'main')
2804
+ if (!main || !main.tip) throw new Error(`${sh.slug || sh.id} has nothing on main: import the session's git remote into it first`)
2805
+ if (main.frozen) throw new Error(`${sh.slug || sh.id}'s main is still frozen for the import's follow: cut the import over first (seam import cutover), then migrate`)
2806
+ const tip = { no: Number(main.patch_count) || null, commit: main.tip }
2807
+ // Every folder reads its square with the room's read token: checked (made again if need be) before anything moves.
2808
+ const rt = await this.freshReadToken(seam, sh.id, sh.read)
2809
+ const squares = []; const based = []; const terms = []; const mergesLeft = []
2810
+ // A failed move leaves no squares behind in the quilt: the ones it opened are abandoned.
2811
+ const abandonAll = async () => { for (const x of squares) if (seam.abandon) await seam.abandon(sh.id, x.id, { reason: 'the session\'s move into the quilt was undone' }).catch(() => {}) }
2812
+ const move = this.startQuiltMove(sh.id)
2813
+ try {
2814
+ for (const key of Object.keys(this.meta.branches || {})) {
2815
+ if (key === this.defaultKey || isLiveKey(key)) continue
2816
+ const want = squareBase(key, this.meta)
2817
+ // No commit names where the branch starts (the relay never fetched it from git): main's tip, said in the answer.
2818
+ if (!want) based.push({ from: key, wanted: '', why: 'unfetched' })
2819
+ let s
2820
+ try { s = await seam.openSquare(sh.id, { title: String(key).slice(0, 200), ...(want ? { base: want } : {}) }) } catch (err) {
2821
+ // Seam has no commit for that sha, or has it inside a square (a branch git merged into
2822
+ // main, imported as the merged square's own commit): main's tip, which holds it.
2823
+ const inSquare = /square's own commit, not a patch/.test(String(err && err.message))
2824
+ if (!want || (err.status !== 404 && !inSquare)) throw err
2825
+ s = await seam.openSquare(sh.id, { title: String(key).slice(0, 200) })
2826
+ based.push({ from: key, wanted: want, ...(inSquare ? { why: 'merged' } : {}) })
2827
+ }
2828
+ squares.push({ from: key, id: s.id })
2829
+ move.opened.push(s.id)
2830
+ this.stillHere()
2831
+ this.saveMeta()
2832
+ const e = this.store.load(key)
2833
+ const files = {}
2834
+ if (e) for (const [p, t] of e.files) files[p] = t.toString()
2835
+ const t = termsFor(e && e.doc ? readMerges(e.doc.getMap('merges')) : [], files, { square: s.id, patchSquare: null, patch: tip })
2836
+ for (const m of t.left) mergesLeft.push({ from: key, path: m.path, id: m.id })
2837
+ if (t.terms.length) {
2838
+ await seam.commit(sh.id, s.id, { expect_head: null, message: `Open merges from session ${this.name}: ${t.terms.map((x) => x.path).join(', ')}`.slice(0, 2000), changes: t.terms })
2839
+ this.stillHere()
2840
+ for (const x of t.terms) terms.push({ from: key, path: x.path, square: s.id })
2841
+ }
2842
+ }
2843
+ } catch (err) {
2844
+ await abandonAll()
2845
+ if (rt.renewed && this.meta.quiltShadow) this.meta.quiltShadow.read = rt.read
2846
+ this.endQuiltMove()
2847
+ throw err
2848
+ }
2849
+ const by = (a && (a.sub || a.key || a.name)) || ''
2850
+ // The room takes the quilt only as layout 3 is written (quiltSwitchNow): until then it is the
2851
+ // shadowing git-mode session it was, for its members, its agents, and a relay that stops.
2852
+ const quilt = { slug: sh.slug || info.slug || '', patch: tip, squares: Object.fromEntries(squares.map((x) => [x.from, x.id])), at: new Date(date).toISOString(), by, history: true, shadow: { ...sh, read: rt.read }, read: rt.read }
2853
+ if (terms.length || mergesLeft.length) quilt.merges = { terms, left: mergesLeft }
2854
+ if (based.length) quilt.basedAtTip = based
2855
+ let switched
2856
+ try {
2857
+ switched = await this.quiltSwitchNow(a, seam, { date, into: { id: sh.id, quilt } })
2858
+ } catch (err) {
2859
+ await abandonAll()
2860
+ // Still the shadowing session, with the read token as it is now (an old one was revoked for it).
2861
+ if (rt.renewed && this.meta.quiltShadow) { this.meta.quiltShadow.read = rt.read; this.saveMeta() }
2862
+ this.endQuiltMove()
2863
+ throw err
2864
+ }
2865
+ const tasks = this.meta.quilt.tasks = this.quiltTasks(this.meta.quilt.squares)
2866
+ this.quiltCommitRequests(this.meta.quilt.squares)
2867
+ this.saveMeta()
2868
+ this.broadcastBranches()
2869
+ return { quilt_id: sh.id, slug: this.meta.quilt.slug, patch: tip, squares, terms, merges_left: mergesLeft, left_out: [], history: true, based_at_tip: based, tasks, ...(rt.renewed ? { read_token: rt.renewed } : {}), ...switched }
2870
+ }, seam)
2871
+ }
2872
+
2266
2873
  /**
2267
2874
  * The layout-3 switch (16, "How a session becomes a quilt"), after quiltMigrate linked the
2268
2875
  * quilt: each branch document is copied into its square's live document on the relay
@@ -2278,84 +2885,799 @@ class Room {
2278
2885
  if (no) throw new Error(no.replace('see what making it a quilt would do', 'move its files into its squares'))
2279
2886
  if (this.meta.layout === LAYOUT_QUILT) throw new Error('this session\'s files are already in its squares')
2280
2887
  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
2888
+ if (!this.meta.quilt_id || !this.meta.quilt) throw new Error('this session is not a quilt yet: make it one first (quilt session migrate)')
2889
+ return this.withMigrationLease(async () => {
2890
+ const rt = await this.freshReadToken(seam, this.meta.quilt_id, this.meta.quilt.read)
2891
+ if (rt.renewed) { this.meta.quilt.read = rt.read; this.saveMeta() }
2892
+ return { ...await this.quiltSwitchNow(a, seam, { date }), ...(rt.renewed ? { read_token: rt.renewed } : {}) }
2893
+ }, seam)
2894
+ }
2895
+
2896
+ /**
2897
+ * "Make this a quilt" (Seam's plans/quilt-source-control/16, flag 9): the owner's one step, from the
2898
+ * app's button, `quilt session migrate`, quilt_migrate_session or POST /v1/sessions/:room/migrate.
2899
+ * A room with no git remote becomes a quilt without history and its files go into its squares
2900
+ * (layout 3) in the same go; a room linked to the quilt its remote's import fills moves with history
2901
+ * (quiltMigrateWithHistory switches itself); a room already a quilt at layout 2 only switches. A
2902
+ * switch that fails leaves the room the layout-2 quilt it just became, every branch document as it
2903
+ * was (nobody's work moves until every copy is checked): the next click only switches.
2904
+ */
2905
+ async quiltMake (a, seam, { date = new Date() } = {}) {
2906
+ if (this.meta.layout === LAYOUT_QUILT) throw new Error(`this session is already the quilt ${(this.meta.quilt && this.meta.quilt.slug) || this.meta.quilt_id}`)
2907
+ if (!this.meta.quilt_id && (this.hasGitRemote() || this.meta.quiltShadow || this.meta.quiltImport)) return this.quiltMakeWithRemote(a, seam, { date })
2908
+ let quilt = null
2909
+ if (!this.meta.quilt_id) {
2910
+ quilt = await this.quiltMigrate(a, seam, { date })
2911
+ if (this.meta.layout === LAYOUT_QUILT) return { ...quilt, layout: LAYOUT_QUILT }
2912
+ } else {
2913
+ const Q = this.meta.quilt || {}
2914
+ quilt = { quilt_id: this.meta.quilt_id, slug: Q.slug || '', patch: Q.patch || null, squares: Object.entries(Q.squares || {}).map(([from, id]) => ({ from, id })), terms: [], merges_left: [], left_out: [], tasks: Q.tasks || {} }
2915
+ }
2916
+ try {
2917
+ return { ...quilt, ...await this.quiltSwitch(a, seam, { date }) }
2918
+ } catch (err) {
2919
+ const e = new Error(`${err.message}. The session is the quilt ${quilt.slug} now, with its files still on its branches: make it a quilt again to move them into its squares`)
2920
+ e.quilt_id = quilt.quilt_id
2921
+ throw e
2922
+ }
2923
+ }
2924
+
2925
+ /**
2926
+ * "Make this a quilt" on a session with a git remote (quilt-remote.js): each click takes the next
2927
+ * step from where the session stands. Nothing moves in git but what Seam's own export pushes after
2928
+ * the cut-over, and the room stays a git-mode session until the move with history, which is
2929
+ * quiltMigrateWithHistory (its own checks undo it, back to layout 2, if it fails). What only the
2930
+ * repository's GitHub owner can do (install Seam's app, add the ruleset) is said as plain steps.
2931
+ */
2932
+ async quiltMakeWithRemote (a, seam, { date = new Date() } = {}) {
2933
+ const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
2934
+ if (no) throw new Error(no.replace('see what making it a quilt would do', 'make it a quilt'))
2935
+ const repo = (this.meta.quiltImport && this.meta.quiltImport.repo) || githubRepoOf(this.meta.upstreams, this.defaultKey)
2936
+ const stage = remoteStage(this.meta)
2937
+ if (stage === 'import') {
2938
+ if (!repo) throw new Error('this session\'s git remote is not on GitHub, so Seam cannot import it from here: import it with follow yourself (seam import git <url> --into <slug> --follow), then link the session to it (quilt session migrate --shadow <slug>) and click Make this a quilt again')
2939
+ const gh = seam.github ? await seam.github() : null
2940
+ if (!gh || gh.configured === false) throw new Error('Seam has no GitHub app set up, so it cannot import this session\'s repository yet')
2941
+ const inst = installationFor(gh, repo)
2942
+ if (!inst) throw stepsError(`Seam can't read ${repo} for you yet. To let it:`, installSteps(repo, gh.app && gh.app.install_url))
2943
+ if (this.quiltMoving) throw new Error('this session is already being moved')
2944
+ let r
2945
+ const name = repo.split('/')[1].toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'repo'
2946
+ try { r = await seam.importGithub({ installation_id: inst.id, repo, follow: true, slug: name }) } catch (err) {
2947
+ if (err.status !== 409) throw new Error(`Seam did not start importing ${repo}: ${err.message}`)
2948
+ r = await seam.importGithub({ installation_id: inst.id, repo, follow: true, slug: `${name}-${this.name.toLowerCase().replace(/[^a-z0-9-]/g, '-')}`.slice(0, 60) })
2949
+ }
2950
+ this.meta.quiltImport = { id: r.id, slug: r.slug || '', repo, installation: inst.id, at: new Date(date).toISOString(), by: (a && (a.sub || a.key || a.name)) || '' }
2951
+ this.saveMeta()
2952
+ this.broadcastBranches()
2953
+ return { stage: 'importing', import: { id: r.id, slug: r.slug || '', repo, state: r.state || 'reading' }, text: `Seam is importing ${repo} into the quilt ${r.slug || ''} and follows it from now on. Your git remote, clones and CI keep working as they are. Click Make this a quilt again once the import follows (a large repository takes a while).` }
2954
+ }
2955
+ if (stage === 'link') {
2956
+ const imp = this.meta.quiltImport
2957
+ const st = await seam.importStatus(imp.id)
2958
+ if (st.state === 'failed') {
2959
+ // A failed import is no use to the session: forgotten here, so the next click starts again.
2960
+ delete this.meta.quiltImport; this.saveMeta(); this.broadcastBranches()
2961
+ throw new Error(`Seam's import of ${imp.repo} failed (${st.error || 'no reason given'}). Click Make this a quilt again to start it over.`)
2962
+ }
2963
+ if (st.state !== 'following') return { stage: 'importing', import: { id: imp.id, slug: imp.slug, repo: imp.repo, state: st.state, commits: st.commits || 0 }, text: `Seam is still importing ${imp.repo} (${st.state}${st.commits ? `, ${st.commits} commits so far` : ''}). Click again in a while.` }
2964
+ await this.quiltShadowLink(a, seam, imp.id)
2965
+ return { stage: 'ready', import: { id: imp.id, slug: imp.slug, repo: imp.repo, state: st.state }, steps: rulesetSteps(imp.repo), text: [`Seam follows ${imp.repo} now, and this session reads its history from the quilt ${imp.slug}. Git stays the authority until you move the session in.`, `Before the move, only Seam may push to main on GitHub. ${imp.repo}'s GitHub owner does this once:`, ...rulesetSteps(imp.repo).map((x, i) => `${i + 1}. ${x}`)].join('\n') }
2966
+ }
2967
+ // The cut-over sitting (16 step 3): only after main takes pushes from Seam's export alone.
2968
+ const id = (this.meta.quiltImport && this.meta.quiltImport.id) || this.meta.quiltShadow.id
2969
+ const st = await seam.importStatus(id).catch((err) => { if (err.status === 404) return null; throw err })
2970
+ if (st && st.state === 'following') {
2971
+ if (repo) {
2972
+ const rules = await this.githubRulesOf(repo, 'main')
2973
+ if (rules === null) throw stepsError(`Quilt could not read ${repo}'s rules for main on GitHub, so it can't tell whether only Seam may push there. Check with the repository's GitHub owner:`, rulesetSteps(repo))
2974
+ if (!mainGuarded(rules)) throw stepsError(`Anyone can still push to main on ${repo}, so a push could land after the move and Seam's export would stop. ${repo}'s GitHub owner does this first:`, rulesetSteps(repo))
2975
+ }
2976
+ try { await seam.cutover(id, {}) } catch (err) {
2977
+ if (err.code === 'remote_moved' || /remote_moved|the remote's/.test(String(err.message))) throw new Error(`Someone pushed to ${repo || 'the remote'} since Seam last looked (${err.message}). Wait for the last pushes and CI to finish, then click Move into Seam now again.`)
2978
+ throw new Error(`Seam did not cut the import over: ${err.message}`)
2979
+ }
2980
+ } else if (st && st.state !== 'done') throw new Error(`Seam's import is ${st.state}, not following: ${st.error || 'see seam import status'}`)
2981
+ const moved = await this.quiltMigrateWithHistory(a, seam, { date })
2982
+ if (this.meta.quiltImport) { delete this.meta.quiltImport; this.saveMeta() }
2983
+ return { ...moved, cut_over: true }
2984
+ }
2985
+
2986
+ /**
2987
+ * GitHub's rules for `branch` of `repo` (owner/name): an array, or null when GitHub could not be
2988
+ * asked. Read with Quilt's GitHub App through a member's connection, else without a token (public).
2989
+ */
2990
+ async githubRulesOf (repo, branch) {
2991
+ if (this.githubRules) return this.githubRules(repo, branch)
2992
+ const cred = await appCredentials(this, repo, { access: 'read' }).catch(() => ({}))
2993
+ const f = this.githubFetch || globalThis.fetch
2994
+ try {
2995
+ const res = await f(`https://api.github.com/repos/${repo}/rules/branches/${encodeURIComponent(branch)}`, { headers: { accept: 'application/vnd.github+json', 'user-agent': 'quilt-relay', ...(cred && cred.token ? { authorization: `Bearer ${cred.token}` } : {}) } })
2996
+ if (!res.ok) return null
2997
+ const rules = await res.json()
2998
+ return Array.isArray(rules) ? rules : null
2999
+ } catch { return null }
3000
+ }
3001
+
3002
+ /**
3003
+ * quiltSwitch's work, inside the room's migration lease. `into` ({ id, quilt }): the quilt the
3004
+ * room becomes as layout 3 is written (the move with history), else the room's own.
3005
+ */
3006
+ async quiltSwitchNow (a, seam, { date = new Date(), into = null } = {}) {
3007
+ const q = into ? into.id : this.meta.quilt_id
3008
+ const Q = into ? into.quilt : this.meta.quilt
3009
+ const move = this.meta.quiltMove || this.startQuiltMove(q)
2285
3010
  const made = []
3011
+ const claimed = []
3012
+ const unwatch = []
3013
+ let adoption = null
2286
3014
  const by = (a && (a.name || a.sub || a.key)) || ''
2287
3015
  try {
2288
- const texts = (key) => {
3016
+ // A branch's files over its square's head (branchFiles): binaries, and what the session never synced, are not deletions.
3017
+ // A square that starts at main's tip for want of the branch's commit keeps main's files the branch never had.
3018
+ const atTip = new Set((Q.basedAtTip || []).map((b) => b.from))
3019
+ const texts = (key, tree) => {
2289
3020
  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
3021
+ return e ? branchFiles(e.doc, tree, { diff: isDiff(e), unheld: atTip.has(key) }) : {}
2293
3022
  }
2294
- const held = (from) => new Set(((this.meta.quilt.merges && this.meta.quilt.merges.terms) || []).filter((t) => t.from === from).map((t) => t.path))
3023
+ // ...nor does a full document there carry the branch's older copies of main's files (git's history of main says which).
3024
+ const mainsOld = async (key, files, head) => {
3025
+ const e = this.store.load(key)
3026
+ if (!atTip.has(key) || !e || isDiff(e)) return files
3027
+ const history = await this.mainHistoryOf([...changedGitIds(files, head).keys()])
3028
+ return history ? dropOldCopies(files, head, history) : files
3029
+ }
3030
+ // Members keep writing their branch documents until layout 3 is written: an edit from now on
3031
+ // (before its files are read, or after) marks its branch, so the copy is made again from what
3032
+ // it holds then and checked with no wait between (the catch-up below). Watched from before any
3033
+ // read: an edit between a read and the copy once failed the check and undid the move.
3034
+ const edits = new Map()
3035
+ const watch = (key) => {
3036
+ if (edits.has(key)) return edits.get(key)
3037
+ const flag = { edited: false }
3038
+ edits.set(key, flag)
3039
+ const e = this.store.load(key)
3040
+ if (e && e.doc) { const mark = () => { flag.edited = true }; e.doc.on('update', mark); unwatch.push(() => e.doc.off('update', mark)) }
3041
+ return flag
3042
+ }
3043
+ for (const key of [this.defaultKey, ...Object.keys(Q.squares || {})]) if (this.meta.branches[key] && !isLiveKey(key)) watch(key)
3044
+ const held = (from) => new Set(((Q.merges && Q.merges.terms) || []).filter((t) => t.from === from).map((t) => t.path))
2295
3045
  const without = (o, skip) => Object.fromEntries(Object.entries(o).filter(([p]) => !skip.has(p)))
2296
3046
  const plan = []
2297
- for (const [key, sq] of Object.entries(this.meta.quilt.squares || {})) {
3047
+ for (const [key, sq] of Object.entries(Q.squares || {})) {
2298
3048
  if (key === this.defaultKey || !this.meta.branches[key] || isLiveKey(key)) continue
2299
3049
  const s = await seam.square(q, sq)
2300
3050
  const head = s.head || s.base || null
2301
3051
  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) })
3052
+ const t = head ? await seam.tree(q, head) : null
3053
+ plan.push({ key, sq, head, skip, answer: t, flag: watch(key), files: without(await mainsOld(key, texts(key, t), treeIds(t)), skip), tree: without(treeIds(t), skip), modes: treeModes(t) })
2303
3054
  }
2304
- const def = texts(this.defaultKey)
2305
- const main = this.meta.quilt.patch ? treeIds(await seam.tree(q, this.meta.quilt.patch.commit)) : {}
3055
+ const mainTree = Q.patch ? await seam.tree(q, Q.patch.commit) : null
3056
+ const main = treeIds(mainTree)
3057
+ const def = texts(this.defaultKey, mainTree)
2306
3058
  const adopt = overlayFor(def, main)
2307
- let adoption = null
2308
- if (Object.keys(adopt.puts).length || adopt.gone.length) {
3059
+ // The default branch's claims need a square too (16's claims row): the adoption square, opened even when the document equals main.
3060
+ const defaultClaims = Object.values(this.meta.claims || {}).some((c) => (this.branchOf(c) || this.defaultKey) === this.defaultKey)
3061
+ // So does a large file in the session's storage that main lacks: the folders that have it put it there.
3062
+ const defaultLarge = [...this.store.load(this.defaultKey).blobs].some(([p, x]) => x && typeof x.data !== 'string' && !x.blob && !main[p])
3063
+ if (Object.keys(adopt.puts).length || adopt.gone.length || defaultClaims || defaultLarge) {
2309
3064
  const s = await seam.openSquare(q, { title: `Uncommitted work from ${this.name}`.slice(0, 200) })
2310
3065
  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 })
3066
+ move.opened.push(s.id)
3067
+ this.stillHere()
3068
+ this.saveMeta()
3069
+ plan.push({ key: this.defaultKey, sq: s.id, head: (Q.patch && Q.patch.commit) || null, skip: new Set(), answer: mainTree, flag: watch(this.defaultKey), files: def, tree: main, modes: treeModes(mainTree) })
3070
+ }
3071
+ // A boxed file (a member's unfinished work the session held while every other folder kept git's
3072
+ // copy) is the box holder's whole file, on the main they had: only its change goes onto the head.
3073
+ const rebased = []
3074
+ const boxesOnto = (p) => {
3075
+ const out = {}
3076
+ for (const [file, v] of Object.entries(p.boxes || {})) {
3077
+ const t = typeof p.files[file] === 'string' ? boxOntoHead(p.files[file], v.head, v.versions) : null
3078
+ if (t !== null) { out[file] = t; p.files[file] = t }
3079
+ }
3080
+ return out
2312
3081
  }
3082
+ for (const p of plan) {
3083
+ p.boxes = {}
3084
+ for (const c of Object.values(this.meta.claims || {})) {
3085
+ const file = c && c.box && (this.branchOf(c) || this.defaultKey) === p.key ? String(c.pattern || '') : ''
3086
+ if (!file || /[*?[]/.test(file) || typeof p.files[file] !== 'string' || !p.head || !p.tree[file] || p.tree[file] === blobId(Buffer.from(p.files[file]))) continue
3087
+ const v = await this.versionsOnLine(seam, q, p.head, file, p.tree[file]).catch(() => null)
3088
+ if (v) p.boxes[file] = v
3089
+ }
3090
+ p.boxTexts = boxesOnto(p)
3091
+ for (const file of Object.keys(p.boxTexts)) rebased.push({ branch: p.key, path: file })
3092
+ }
3093
+ if (rebased.length) this.log(`[${this.name}] the move puts ${rebased.length} boxed file(s) onto main's newer copy as their change only: ${rebased.map((x) => x.path).slice(0, 10).join(', ')}`)
3094
+ // A small binary a branch changed goes to Seam first: its live entry names the blob.
3095
+ const uploaded = new Set()
3096
+ const upload = async (p) => {
3097
+ const up = binariesToUpload(p.files, p.tree).filter((b) => !uploaded.has(b.id))
3098
+ if (up.length) await seam.upload(q, up)
3099
+ for (const b of up) uploaded.add(b.id)
3100
+ return up.length
3101
+ }
3102
+ for (const p of plan) await upload(p)
3103
+ this.stillHere()
2313
3104
  for (const p of plan) {
2314
3105
  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 })
3106
+ if (this.meta.branches[lk] && this.store.load(lk) && liveMaps(this.store.load(lk).doc).live.size) throw new Error(`square ${squareShort(p.sq)} already has a live document on the relay`)
2317
3107
  made.push(lk)
2318
- writeOverlay(this.store.load(lk).doc, overlayFor(p.files, p.tree), p.head, by)
3108
+ move.made.push(lk)
3109
+ this.noteBranch(lk, { by })
3110
+ // A copy of the branch's Y.Doc, so the edit history inside it survives (16), made an overlay over the square's head.
3111
+ const from = this.store.load(p.key)
3112
+ copyIntoLive(this.store.load(lk).doc, from.doc, { files: p.files, head: p.tree, modes: p.modes, commit: p.head, by, skip: p.skip })
3113
+ writeIntoLive(this.store.load(lk).doc, p.boxTexts, p.tree, p.head)
2319
3114
  }
2320
- for (const p of plan) {
3115
+ const check = (p) => {
2321
3116
  const bad = checkOverlay(this.store.load(liveKey(p.sq)).doc, p.files, p.tree)
2322
3117
  if (bad) throw new Error(`the check after copying ${p.key === this.defaultKey ? 'the default branch' : p.key} failed: ${bad}`)
2323
3118
  }
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()
3119
+ // A branch edited since its files were read is copied and checked again in the catch-up below.
3120
+ for (const p of plan) if (!p.flag.edited) check(p)
3121
+ // Claims move to Seam's claims, on the square made from their branch (16's claims row).
3122
+ const squareOf = Object.fromEntries(plan.map((p) => [p.key, p.sq]))
3123
+ const claimsLeft = []
3124
+ // Claims made while the claims move (or the move waits on Seam) move too: until a look finds none new.
3125
+ const seen = new Set()
3126
+ for (let round = 0; round < MOVE_CATCH_UP_ROUNDS; round++) {
3127
+ const fresh = Object.entries(this.meta.claims || {}).filter(([k]) => !seen.has(k))
3128
+ if (!fresh.length) break
3129
+ for (const [k, c] of fresh) {
3130
+ seen.add(k)
3131
+ const sq = squareOf[this.branchOf(c) || this.defaultKey]
3132
+ if (!sq) { claimsLeft.push({ pattern: c.pattern, branch: this.branchOf(c) || this.defaultKey, by: c.by || '', why: 'no square' }); continue }
3133
+ try {
3134
+ // Seam makes a claim only as its caller (the mover), so the note says whose it was, and the
3135
+ // record keeps them: that person (or the owner) lets go of it from Quilt (quilt-claims).
3136
+ const r = await seam.claim(q, sq, { pattern: c.pattern, note: `${c.by || 'someone'}'s claim from the session${c.note ? `: ${c.note}` : ''}`.slice(0, 280) })
3137
+ claimed.push({ square: sq, id: r && r.id, pattern: c.pattern, by: c.by || '', ...(c.byId ? { byId: c.byId } : {}) })
3138
+ move.claimed.push({ square: sq, id: (r && r.id) || c.pattern })
3139
+ } catch (err) { claimsLeft.push({ pattern: c.pattern, branch: this.branchOf(c) || this.defaultKey, by: c.by || '', why: err.message }) }
3140
+ this.stillHere()
3141
+ this.saveMeta()
3142
+ }
3143
+ }
3144
+ // The post-run check (16, "Dry run and check"): every claim moved names an open square.
3145
+ if (claimed.length) {
3146
+ const now = await seam.claims(q)
3147
+ for (const c of claimed) {
3148
+ const row = (now || []).find((x) => x && (x.id === c.id || (x.square_id === c.square && x.pattern === c.pattern)))
3149
+ if (!row) throw new Error(`the claim on ${c.pattern} is not in the quilt after moving it`)
3150
+ const s = await seam.square(q, c.square)
3151
+ if (!s || s.state !== 'open') throw new Error(`the claim on ${c.pattern} names square ${squareShort(c.square)}, which is ${s ? s.state : 'gone'}`)
3152
+ }
3153
+ }
3154
+ // Every person in the session writes in every square, not only the holder who moved it, so their AIs (working for them) commit there too.
3155
+ const people = [...Object.entries(this.meta.members || {}).map(([key, m]) => ({ key, role: m && m.role })), ...(this.meta.ownerSub ? [{ key: this.meta.ownerSub, role: 'editor' }] : [])]
3156
+ const notShared = []
3157
+ if (seam.share) {
3158
+ for (const p of plan) {
3159
+ try { for (const x of await shareWithPeople(seam, q, p.sq, people)) notShared.push({ square: p.sq, ...x }) } catch (err) { notShared.push({ square: p.sq, member: '', why: String(err.message || err).slice(0, 200) }) }
3160
+ }
3161
+ }
3162
+ this.stillHere()
3163
+ // Edits that reached a branch document while the move waited on Seam go into its square too,
3164
+ // copied again and checked with no wait between the last copy and layout 3 (a new binary is
3165
+ // uploaded first, then the branches are looked at again).
3166
+ for (let round = 0; ; round++) {
3167
+ // `stale`: read again but not copied yet (a binary had to go to Seam first, or another edit came).
3168
+ const edited = plan.filter((p) => p.flag.edited || p.stale)
3169
+ if (!edited.length) break
3170
+ if (round >= MOVE_CATCH_UP_ROUNDS) throw new Error('edits kept arriving while the session moved; try again when it is quieter')
3171
+ let waited = 0
3172
+ for (const p of edited) { p.flag.edited = false; p.stale = true; p.files = without(await mainsOld(p.key, texts(p.key, p.answer), treeIds(p.answer)), p.skip); p.boxTexts = boxesOnto(p); waited += await upload(p) }
3173
+ this.stillHere()
3174
+ // Copied only from files read since the branch's last edit: an edit during the reads looks again.
3175
+ if (waited || edited.some((p) => p.flag.edited)) continue
3176
+ for (const p of edited) {
3177
+ copyIntoLive(this.store.load(liveKey(p.sq)).doc, this.store.load(p.key).doc, { files: p.files, head: p.tree, modes: p.modes, commit: p.head, by, skip: p.skip })
3178
+ writeIntoLive(this.store.load(liveKey(p.sq)).doc, p.boxTexts, p.tree, p.head)
3179
+ check(p)
3180
+ p.stale = false
3181
+ }
3182
+ }
3183
+ Q.live = Object.fromEntries(plan.map((p) => [p.key, liveKey(p.sq)]))
3184
+ // Large files in the session's storage that git's head lacks: the relay can't read their bytes,
3185
+ // so they are not in the squares yet; each folder that has them puts them in when it follows.
3186
+ const large = []
3187
+ for (const p of plan) {
3188
+ for (const [path, x] of this.store.load(p.key).blobs) if (x && typeof x.data !== 'string' && !x.blob && !p.tree[path] && !p.skip.has(path)) large.push({ branch: p.key, path, size: Number(x.size) || 0 })
3189
+ }
3190
+ if (large.length) Q.large = large
3191
+ if (adoption) Q.adoption = adoption
3192
+ if (claimed.length || claimsLeft.length) Q.claims = { moved: claimed, left: claimsLeft }
3193
+ Q.layoutAt = new Date(date).toISOString()
3194
+ // Git's bookkeeping leaves the room (16: dropped), kept for a rollback.
3195
+ Q.kept = takeGitMeta(this.meta)
3196
+ if (into) { this.meta.quilt_id = into.id; this.meta.quilt = Q; delete this.meta.quiltShadow }
3197
+ delete this.meta.quiltMove
2327
3198
  this.meta.layout = LAYOUT_QUILT // last
2328
3199
  this.saveMeta()
2329
3200
  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) }
3201
+ if (!ws.hosted && !this.supported(ws)) { ws.endReason = 'needs_update'; ws.close(CLOSE_NEEDS_UPDATE, this.needsUpdate()) }
2331
3202
  }
2332
3203
  this.broadcastBranches()
2333
- return { layout: LAYOUT_QUILT, live: this.meta.quilt.live, adoption }
3204
+ return { layout: LAYOUT_QUILT, live: Q.live, adoption, claims: { moved: claimed.length, left: claimsLeft }, ...(rebased.length ? { rebased } : {}), ...(large.length ? { large } : {}), ...(notShared.length ? { not_shared: notShared } : {}) }
2334
3205
  } catch (err) {
3206
+ for (const c of claimed) if (seam.unclaim) await seam.unclaim(q, c.square, c.id || c.pattern).catch(() => {})
3207
+ if (adoption && seam.abandon) await seam.abandon(q, adoption, { reason: 'the session\'s move into its squares was undone' }).catch(() => {})
2335
3208
  for (const lk of made) this.removeBranch(lk)
2336
3209
  if (made.length) this.broadcastBranches()
3210
+ if (!into) this.endQuiltMove()
2337
3211
  throw new Error(`Migration undone: ${err.message}. The session stays as it was.`)
2338
3212
  } finally {
2339
- this.quiltMoving = false
3213
+ for (const off of unwatch) off()
3214
+ }
3215
+ }
3216
+
3217
+ /**
3218
+ * A branch's square merged, and Seam never changes a merged square again: the branch moves to a
3219
+ * new square on the same line, opened as the member asking (`seam` acting as them) and shared
3220
+ * with the merged one's holder and people writers, and its live document carries on from the old
3221
+ * one (carryLive: what the merge has leaves, later work stays), so every folder on the branch
3222
+ * keeps working. Any editor may ask; a square still open is left as it is. One move at a time.
3223
+ */
3224
+ async quiltNext (a, seam, branch) {
3225
+ // A move running now (going back, say) settles first: a branch moves on from where it leaves the session.
3226
+ if (this.quiltMoving && this.quiltMoved) await this.quiltMoved
3227
+ if (this.meta.layout !== LAYOUT_QUILT) throw new Error('this session\'s files are not in squares')
3228
+ if (!a || a.role === 'viewer') throw new Error('only someone who may change files moves a branch to its next square')
3229
+ const key = String(branch || '')
3230
+ const run = async () => {
3231
+ if (this.meta.layout !== LAYOUT_QUILT) throw new Error('this session\'s files are not in squares')
3232
+ const q = this.meta.quilt_id
3233
+ // A read token gone or about to end is made again by whoever moves on, when Seam lets them (an admin).
3234
+ const rt = await this.freshReadToken(seam, q, this.meta.quilt.read, { probe: false }).catch(() => null)
3235
+ if (rt && rt.renewed) this.takeReadToken(rt.read)
3236
+ const lk = (this.meta.quilt.live || {})[key]
3237
+ if (!isLiveKey(lk)) throw new Error(`${key || 'that branch'} has no square in this session`)
3238
+ const old = lk.slice(liveKey('').length)
3239
+ const s = await seam.square(q, old)
3240
+ if (!s || s.state === 'open' || s.state === 'merging') return { square: old, moved: false }
3241
+ const n = await seam.openSquare(q, { title: String(s.title || `Work in ${this.name}`).replace(/^(Follow-up: )+/, '').slice(0, 200), line: (s.merged && s.merged.line) || s.line || 'main' })
3242
+ for (const m of new Set([s.holder, ...(s.writers || [])])) {
3243
+ if (m && m !== n.holder && String(m).startsWith('person:') && seam.share) await seam.share(q, n.id, { member: m }).catch(() => {})
3244
+ }
3245
+ const head = n.base || n.head || null
3246
+ let tree = {}
3247
+ try { if (head) tree = treeIds(await seam.tree(q, head)) } catch (err) { await this.abandonOpened(seam, q, n.id); throw err }
3248
+ // Going back started while this waited on Seam (quiltUnswitch waits for it): the square it opened goes.
3249
+ if (this.meta.layout !== LAYOUT_QUILT || this.quiltMoving) { await this.abandonOpened(seam, q, n.id); throw new Error('the session is going back to its branches') }
3250
+ const nk = liveKey(n.id)
3251
+ const by = (a && (a.name || a.sub || a.key)) || ''
3252
+ this.noteBranch(nk, { by })
3253
+ carryLive(this.store.load(nk).doc, this.store.load(lk).doc, { head: tree, commit: head, by })
3254
+ this.meta.quilt.live = { ...this.meta.quilt.live, [key]: nk }
3255
+ // A folder still on the old square for a moment (it has not heard yet) keeps typing there: its edits go on to this one (handle, movedOnTo).
3256
+ this.meta.quilt.next = Object.fromEntries([...Object.entries(this.meta.quilt.next || {}), [lk, nk]].slice(-MOVED_ON_KEPT))
3257
+ this.saveMeta()
3258
+ this.broadcastBranches()
3259
+ return { square: n.id, moved: true, from: old }
3260
+ }
3261
+ if (!this.quiltNexts) this.quiltNexts = new Map()
3262
+ const was = this.quiltNexts.get(key) || Promise.resolve()
3263
+ const p = was.catch(() => {}).then(run)
3264
+ this.quiltNexts.set(key, p)
3265
+ try { return await p } finally { if (this.quiltNexts.get(key) === p) this.quiltNexts.delete(key) }
3266
+ }
3267
+
3268
+ /**
3269
+ * Bringing main in (source-live/bring-in.js): a member's app asks, as that member (`seam`), each time
3270
+ * it checks its folder's square (session-follow.js catchUp). When main moved past the square's base,
3271
+ * the square takes main's new patches: Seam rebases its commits, and its live document moves onto the
3272
+ * new head with each file it changed combined three ways. Seam's server never moves a square by
3273
+ * itself (06): one of its members does, the holder first, and a writer once the holder's app has not
3274
+ * asked for a while (offline). One at a time per branch, with its moves (quiltNext). Answers
3275
+ * { square, behind, conflicted?, waiting?, brought? }.
3276
+ */
3277
+ async quiltCatchUp (a, seam, branch, { by = '' } = {}) {
3278
+ if (this.meta.layout !== LAYOUT_QUILT) throw new Error('this session\'s files are not in squares')
3279
+ const key = String(branch || '')
3280
+ const lk = (this.meta.quilt.live || {})[key]
3281
+ if (!isLiveKey(lk)) throw new Error(`${key || 'that branch'} has no square in this session`)
3282
+ const id = lk.slice(liveKey('').length)
3283
+ const q = this.meta.quilt_id
3284
+ const sq = await seam.square(q, id)
3285
+ const answer = (more = {}) => ({ square: id, behind: (sq && sq.behind_by) || 0, ...(sq && sq.conflicted_paths ? { conflicted: sq.conflicted_paths } : {}), ...more })
3286
+ const e = this.store.load(lk)
3287
+ if (!sq || sq.state !== 'open' || !e) return answer({ behind: 0 })
3288
+ if (!this.catchUps) this.catchUps = new Map()
3289
+ if (!sq.behind_by && liveHead(e.doc) === (sq.head || sq.base)) { this.catchUps.delete(id); return answer() }
3290
+ if (!a || a.role === 'viewer') return answer()
3291
+ let st = this.catchUps.get(id)
3292
+ if (!st) this.catchUps.set(id, (st = { since: Date.now() }))
3293
+ const me = await seam.quilt(q).then((x) => (x && x.you && x.you.member_id) || null, () => null)
3294
+ const holder = !!me && me === sq.holder
3295
+ const wait = this.catchUpWaitMs ?? HOLDER_WAIT_MS
3296
+ if (holder) st.holderAt = Date.now()
3297
+ else if (Date.now() - st.since < wait || (st.holderAt && Date.now() - st.holderAt < wait)) return answer({ waiting: sq.holder_name || sq.holder || 'its holder' })
3298
+ const run = () => this.bringMainIn(seam, q, key, lk, st, by)
3299
+ if (!this.quiltNexts) this.quiltNexts = new Map()
3300
+ const was = this.quiltNexts.get(key) || Promise.resolve()
3301
+ const p = was.catch(() => {}).then(run)
3302
+ this.quiltNexts.set(key, p)
3303
+ try { return await p } catch (err) {
3304
+ // The holder's app could not (its Seam sign-in, say): a writer's goes next time it asks.
3305
+ if (holder) st.holderAt = 0
3306
+ throw err
3307
+ } finally { if (this.quiltNexts.get(key) === p) this.quiltNexts.delete(key) }
3308
+ }
3309
+
3310
+ /**
3311
+ * The folders on square `lk` too old to follow its base moving (a Quilt before 0.3.133: it never
3312
+ * re-projected, nor said which head an edit was made on, so it would write the old base's files back
3313
+ * into the square): their people's names. A folder is known to follow a moving base by `quilt-moves`
3314
+ * (protocol.js), or by its app having asked to bring main in (0.3.133 and 0.3.134 do, every minute).
3315
+ */
3316
+ outdatedOn (lk) {
3317
+ const out = new Set()
3318
+ for (const ws of this.conns.keys()) {
3319
+ if (ws.branch !== lk || (ws.features || []).includes('quilt-moves')) continue
3320
+ const name = this.names.get(ws) || ''
3321
+ if (this.movesApps && this.movesApps.has(appOf(ws, name))) continue
3322
+ out.add(name || 'someone')
3323
+ }
3324
+ return [...out].sort()
3325
+ }
3326
+
3327
+ /** quiltCatchUp's work, its turn come: the square read again, its live conflicts committed first, Seam's rebase, the live move. */
3328
+ async bringMainIn (seam, q, key, lk, st, by) {
3329
+ if (this.meta.layout !== LAYOUT_QUILT || (this.meta.quilt.live || {})[key] !== lk || this.quiltMoving) return { square: lk.slice(liveKey('').length), behind: 0 }
3330
+ const id = lk.slice(liveKey('').length)
3331
+ const sq = await seam.square(q, id)
3332
+ if (!sq || sq.state !== 'open') return { square: id, behind: 0 }
3333
+ // Main waits while a folder on the square can't follow its base moving: it would put the old base's
3334
+ // copies back as its edits (2026-11-01). It comes in once that app is updated, or leaves the square.
3335
+ const outdated = sq.behind_by ? this.outdatedOn(lk) : []
3336
+ if (outdated.length) return { square: id, behind: sq.behind_by, outdated }
3337
+ const doc = this.store.load(lk).doc
3338
+ const done = (more = {}) => ({ square: id, behind: 0, ...more })
3339
+ // The live document is not on the square's head (a commit's seal on its way, or lost with a relay
3340
+ // restart): given a moment, it moves onto it, combined, as after a rebase.
3341
+ if (liveHead(doc) !== (sq.head || sq.base)) {
3342
+ st.stale = st.stale || Date.now()
3343
+ if (Date.now() - st.stale < (this.catchUpWaitMs ?? HOLDER_WAIT_MS) / 5) return { square: id, behind: sq.behind_by || 0, waiting: 'a commit' }
3344
+ await this.moveSquareLive(seam, q, lk, doc, { prev: liveHead(doc), head: sq.head || sq.base, base: !sq.head, by })
3345
+ st.stale = 0
3346
+ }
3347
+ if (!sq.behind_by) { this.catchUps.delete(id); return done() }
3348
+ // An older copy of main's file in the live work is nobody's edit: main's stands, never a "conflict" with main.
3349
+ const stale = await staleAhead(seam, q, sq, doc, sq.line || 'main').catch((err) => { this.log(`[${this.name}] could not look for older copies of main's files in square ${squareShort(id)}: ${err.message}`); return [] })
3350
+ if (stale.length) {
3351
+ standHead(doc, stale, liveHead(doc))
3352
+ this.log(`[${this.name}] square ${squareShort(id)}: ${stale.length} live file(s) were older copies of main's, not edits; main's stand (${stale.slice(0, 5).join(', ')}${stale.length > 5 ? ', …' : ''})`)
3353
+ }
3354
+ const ahead = await conflictsAhead(seam, q, sq, doc, sq.line || 'main')
3355
+ const head = ahead.length ? await commitAhead(seam, q, sq, doc, ahead, by) : sq.head
3356
+ const r = await seam.rebase(q, id, { expect_head: head || null })
3357
+ if (r.noop || !r.square) { this.catchUps.delete(id); return done() }
3358
+ const to = r.square.head || r.square.base
3359
+ const terms = [...new Set([...(r.terms || []).map((t) => t.path), ...ahead])]
3360
+ const m = await this.moveSquareLive(seam, q, lk, doc, { prev: head || sq.base, head: to, base: !r.square.head, by, brought: { patches: sq.behind_by, conflicts: terms, ...(stale.length ? { stale: stale.slice(0, 50) } : {}) } })
3361
+ this.catchUps.delete(id)
3362
+ this.log(`[${this.name}] brought ${sq.behind_by} patch(es) of ${sq.line || 'main'} into square ${squareShort(id)} as ${by || 'a member'}${m.combined.length ? `, combining ${m.combined.length} live file(s)` : ''}${terms.length ? `; ${terms.length} conflict(s) kept in the square` : ''}`)
3363
+ return done({ brought: { patches: sq.behind_by, combined: m.combined, conflicts: [...new Set([...terms, ...m.conflicts])], ...(stale.length ? { stale: stale.slice(0, 50) } : {}) }, ...(r.square.conflicted_paths ? { conflicted: r.square.conflicted_paths } : {}) })
3364
+ }
3365
+
3366
+ /** A square's live document moved onto `head` (bring-in.js moveLive), late edits made on the old head combined after (lateEdits). */
3367
+ async moveSquareLive (reader, q, lk, doc, opts) {
3368
+ const r = await moveLive(reader, q, doc, opts)
3369
+ if (!this.lateMoves) this.lateMoves = new Map()
3370
+ if (this.lateMoves.has(lk)) this.lateMoves.get(lk)()
3371
+ this.lateMoves.set(lk, lateEdits(doc, { from: opts.prev, to: opts.head, read: sideReader(this.quiltReadClient() || reader, q) }))
3372
+ return r
3373
+ }
3374
+
3375
+ /**
3376
+ * A folder's live block asks (live-block.js moveTo): Seam moved its square's head itself (seam_rebase,
3377
+ * a settled conflict, a merge that kept its conflict), and the live document follows, its files
3378
+ * combined with what the move changed, read with the room's read token.
3379
+ */
3380
+ async liveMove (ws, req) {
3381
+ const key = this.resolveKey(String(req.branch || ''))
3382
+ if (!isLiveKey(key) || !(process.env.QUILT_SOURCE || this.meta.layout === LAYOUT_QUILT)) throw new Error('only a square\'s live document moves')
3383
+ const e = key === ws.branch ? this.store.get(key) : null
3384
+ if (!e) throw new Error(`you are not on ${req.branch}`)
3385
+ const a = this.access.get(ws)
3386
+ if (!a || a.role === 'viewer') throw new Error('only someone who may change files moves a square\'s live work')
3387
+ const head = String(req.head || '')
3388
+ if (!/^[0-9a-f]{64}$/.test(head)) throw new Error('move needs the square\'s new head')
3389
+ const prev = liveHead(e.doc)
3390
+ if (req.prev && req.prev !== prev) throw new Error(`the square's live head is ${String(prev || 'not set').slice(0, 12)}, not ${String(req.prev).slice(0, 12)}: read it again`)
3391
+ if (prev === head) return { head, moved: false }
3392
+ const by = this.names.get(ws) || ''
3393
+ // Squares in a session that is not a quilt (QUILT_SOURCE alone) have no read token to read the move with: the live work moves as it is.
3394
+ if (this.meta.layout !== LAYOUT_QUILT) { seal(e.doc, { prev, head, by }); if (req.base) liveMaps(e.doc).live.set('head', { commit: head, by, ts: Date.now(), base: true }); return { head, moved: true } }
3395
+ const reader = this.quiltReadClient()
3396
+ if (!reader) { this.readTokenRefused(); throw new Error(this.noReadToken()) }
3397
+ const r = await this.moveSquareLive(reader, this.meta.quilt_id, key, e.doc, { prev, head, base: !!req.base, by })
3398
+ return { head, moved: true, combined: r.combined, conflicts: r.conflicts }
3399
+ }
3400
+
3401
+ /** A square a branch move opened that the session no longer wants (it went back meanwhile), abandoned in Seam. */
3402
+ async abandonOpened (seam, q, id) {
3403
+ if (seam.abandon) await seam.abandon(q, id, { reason: 'the session went back to its branches while it opened' }).catch(() => {})
3404
+ }
3405
+
3406
+ /**
3407
+ * A branch with no square at layout 3 (the default branch when it equalled main's tip and held no
3408
+ * claim, so the move opened no adoption square; a branch made after a quilt was made without
3409
+ * history; a branch checked out in git after the move, which the relay has no copy of): its folders' writes reach only its read-only document, so the first member who writes
3410
+ * there asks for one (`quilt-open`, the app's readOnly answer). It is opened as that member (its
3411
+ * holder), at the commit the branch document is of (the move's base for it: its ref's imported
3412
+ * commit, else the quilt's patch at the move, never main's tip now, which would undo what merged
3413
+ * since), the branch document copied into its live document and checked as the move does, and
3414
+ * every folder on the branch follows it; the asker's refused edits go in as theirs.
3415
+ */
3416
+ async quiltOpen (a, seam, branch) {
3417
+ if (this.meta.layout !== LAYOUT_QUILT) throw new Error('this session\'s files are not in squares')
3418
+ if (!a || a.role === 'viewer') throw new Error('only someone who may change files opens a square for a branch')
3419
+ if (this.quiltMoving && this.quiltMoved) await this.quiltMoved
3420
+ if (this.meta.layout !== LAYOUT_QUILT) throw new Error('this session\'s files are not in squares')
3421
+ const key = this.resolveKey(String(branch || ''))
3422
+ if (!key || isLiveKey(key) || !validBranchKey(key)) throw new Error(`"${String(key).slice(0, 60)}" isn't a branch name`)
3423
+ const run = async () => {
3424
+ if (this.meta.layout !== LAYOUT_QUILT) throw new Error('this session\'s files are not in squares')
3425
+ const Q = this.meta.quilt
3426
+ const q = this.meta.quilt_id
3427
+ const rt = await this.freshReadToken(seam, q, Q.read, { probe: false }).catch(() => null)
3428
+ if (rt && rt.renewed) this.takeReadToken(rt.read)
3429
+ if ((Q.live || {})[key]) return { square: Q.live[key].slice(liveKey('').length), opened: false }
3430
+ // A branch the relay has no copy of (checked out in git after the move): a square at main's tip
3431
+ // with nothing in it, held by the asker; their app takes the folder in as it is (folderWins).
3432
+ if (!this.meta.branches[key]) {
3433
+ const s = await seam.openSquare(q, { title: `${key} in ${this.name}`.slice(0, 200) })
3434
+ if (this.meta.layout !== LAYOUT_QUILT || this.quiltMoving) { await this.abandonOpened(seam, q, s.id); throw new Error('the session is going back to its branches') }
3435
+ const by = (a && (a.name || a.sub || a.key)) || ''
3436
+ const lk = liveKey(s.id)
3437
+ this.noteBranch(lk, { by })
3438
+ startHead(this.store.load(lk).doc, s.head || s.base, by)
3439
+ Q.live = { ...Q.live, [key]: lk }
3440
+ Q.opened = [...(Q.opened || []), { from: key, id: s.id, by, at: new Date().toISOString(), fresh: true }]
3441
+ this.saveMeta()
3442
+ this.broadcastBranches()
3443
+ this.log(`[${this.name}] opened square ${squareShort(s.id)} at main's tip for ${key}, a branch the session had no copy of (asked by ${by || 'a member'})`)
3444
+ return { square: s.id, opened: true, fresh: true }
3445
+ }
3446
+ const want = squareBase(key, { ...this.meta, ...(Q.kept || {}) }) || (Q.patch && Q.patch.commit) || null
3447
+ let s
3448
+ try { s = await seam.openSquare(q, { title: (key === this.defaultKey ? `Work on ${key} in ${this.name}` : String(key)).slice(0, 200), ...(want ? { base: want } : {}) }) } catch (err) {
3449
+ if (!want || err.status !== 404) throw err
3450
+ s = await seam.openSquare(q, { title: String(key).slice(0, 200), ...(Q.patch && Q.patch.commit && want !== Q.patch.commit ? { base: Q.patch.commit } : {}) })
3451
+ }
3452
+ const head = s.head || s.base || null
3453
+ const by = (a && (a.name || a.sub || a.key)) || ''
3454
+ const lk = liveKey(s.id)
3455
+ try {
3456
+ const t = head ? await seam.tree(q, head) : null
3457
+ const e = this.store.load(key)
3458
+ const files = branchFiles(e.doc, t, { diff: isDiff(e) })
3459
+ const tree = treeIds(t)
3460
+ const up = binariesToUpload(files, tree)
3461
+ if (up.length) await seam.upload(q, up)
3462
+ if (this.meta.layout !== LAYOUT_QUILT || this.quiltMoving) throw new Error('the session is going back to its branches')
3463
+ this.noteBranch(lk, { by })
3464
+ copyIntoLive(this.store.load(lk).doc, e.doc, { files, head: tree, modes: treeModes(t), commit: head, by })
3465
+ const bad = checkOverlay(this.store.load(lk).doc, files, tree)
3466
+ if (bad) throw new Error(`the check after copying ${key} failed: ${bad}`)
3467
+ } catch (err) {
3468
+ if (this.meta.branches[lk]) this.removeBranch(lk)
3469
+ if (seam.abandon) await seam.abandon(q, s.id, { reason: 'opening it for the branch failed' }).catch(() => {})
3470
+ throw err
3471
+ }
3472
+ Q.live = { ...Q.live, [key]: lk }
3473
+ Q.opened = [...(Q.opened || []), { from: key, id: s.id, by, at: new Date().toISOString() }]
3474
+ this.saveMeta()
3475
+ this.broadcastBranches()
3476
+ this.log(`[${this.name}] opened square ${squareShort(s.id)} for ${key}, which had none (asked by ${by || 'a member'})`)
3477
+ return { square: s.id, opened: true }
2340
3478
  }
3479
+ // One square per branch: an ask while one opens waits for it, and finds it open.
3480
+ if (!this.quiltNexts) this.quiltNexts = new Map()
3481
+ const was = this.quiltNexts.get(key) || Promise.resolve()
3482
+ const p = was.catch(() => {}).then(run)
3483
+ this.quiltNexts.set(key, p)
3484
+ try { return await p } finally { if (this.quiltNexts.get(key) === p) this.quiltNexts.delete(key) }
2341
3485
  }
2342
3486
 
2343
- /** Back to layout 2 from the kept branch documents (16's rollback before R3). The squares' live documents stay. */
2344
- quiltUnswitch (a) {
3487
+ /**
3488
+ * Back to layout 2 from the kept branch documents (16's rollback before R3). The squares' live
3489
+ * documents stay. Refused while the quilt has open squares (16: export never includes squares),
3490
+ * unless each is named in `carried` (its work carried over by hand, `seam diff | git apply`);
3491
+ * merged and abandoned squares are no longer open. Git's bookkeeping comes back, and a session
3492
+ * that became its import's quilt (with history) is a git-mode session shadowing it again, so its
3493
+ * git-mode sync resumes and agents have their git path back.
3494
+ */
3495
+ async quiltUnswitch (a, seam, { carried = [] } = {}) {
2345
3496
  const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
2346
3497
  if (no) throw new Error(no.replace('see what making it a quilt would do', 'move it back to branches'))
2347
3498
  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 }
3499
+ if (!seam) throw new Error('sign in to Seam first: going back reads the quilt\'s open squares as its owner')
3500
+ return this.withMigrationLease(async () => {
3501
+ // A branch moving on to its next square, or opening one, finishes first: it sees the move and lets go of what it opened.
3502
+ await Promise.allSettled([...(this.quiltNexts || new Map()).values()])
3503
+ const q = this.meta.quilt_id
3504
+ // Squares carried at an earlier rollback (a session that went back and moved again) stay carried.
3505
+ const before = (this.meta.quilt.shadow && this.meta.quilt.shadow.rolledBack && this.meta.quilt.shadow.rolledBack.carried) || []
3506
+ const was = new Set([...before, ...(this.meta.quilt.carried || []), ...(Array.isArray(carried) ? carried.map(String) : [])])
3507
+ const open = ((await seam.squares(q, { state: 'open' })) || []).filter((s) => s && s.state !== 'merged' && s.state !== 'abandoned')
3508
+ // A square is named by its id, or as Seam names it (the end of its id, sq:<8 or more>), or by a
3509
+ // start of it (8 or more), when that names no other open square: squares opened together share their first 8.
3510
+ const bare = (id) => String(id).replace(/-/g, '').toLowerCase()
3511
+ const fits = (id, x) => { const y = bare(x); return y.length >= 8 && (bare(id).endsWith(y) || bare(id).startsWith(y)) }
3512
+ const names = (s) => was.has(s.id) || [...was].some((x) => fits(s.id, x) && open.filter((o) => fits(o.id, x)).length === 1)
3513
+ this.stillHere()
3514
+ const left = open.filter((s) => !names(s))
3515
+ if (left.length) {
3516
+ throw Object.assign(new Error(`${left.length} square${left.length === 1 ? ' is' : 's are'} still open in ${this.meta.quilt.slug || q}: ${left.slice(0, 10).map((s) => `${squareShort(s.id)} "${String(s.title || '').slice(0, 60)}"`).join(', ')}${left.length > 10 ? ', …' : ''}. Merge or abandon each, or carry its work over by hand (seam diff <square> | git apply) and name it as carried.`), { open: left.map((s) => ({ id: s.id, title: s.title || '' })) })
3517
+ }
3518
+ this.meta.quilt.carried = [...was]
3519
+ // A square no folder follows now (its folders are offline, or gone for good: a replaced cloud
3520
+ // AI machine) would keep its work only on the relay: it is committed in the square, which stays
3521
+ // readable in Seam, and copied into its branch document (foldSquareBack). A square a folder
3522
+ // follows comes back through that folder (Session.leaveSquare).
3523
+ const kept = []
3524
+ for (const [key, lk] of Object.entries(this.meta.quilt.live || {})) {
3525
+ const on = this.store.get(lk)
3526
+ if (on && on.conns.size) continue
3527
+ const r = await this.foldSquareBack(seam, q, key, lk).catch((err) => ({ branch: key, square: lk.slice(liveKey('').length), error: err.message }))
3528
+ this.stillHere()
3529
+ if (r && (r.error || r.copied.length || r.left.length || r.uncommitted)) kept.push(r)
3530
+ }
3531
+ // Nobody follows a carried square any more (its work is back in the branches): it is abandoned, so its claims let go.
3532
+ for (const s of open) if (seam.abandon) await seam.abandon(q, s.id, { reason: 'carried back to the session\'s branches (quilt session migrate --back)' }).catch(() => {})
3533
+ restoreGitMeta(this.meta, this.meta.quilt.kept)
3534
+ delete this.meta.quilt.kept
3535
+ this.meta.layout = 2
3536
+ this.meta.quilt.unswitchedAt = new Date().toISOString()
3537
+ let shadow = null
3538
+ if (this.meta.quilt.history && this.meta.quilt.shadow) {
3539
+ shadow = this.meta.quilt.shadow
3540
+ this.meta.quiltShadow = { ...shadow, rolledBack: { at: this.meta.quilt.unswitchedAt, squares: this.meta.quilt.squares, adoption: this.meta.quilt.adoption || null, carried: this.meta.quilt.carried } }
3541
+ delete this.meta.quilt_id
3542
+ delete this.meta.quilt
3543
+ }
3544
+ this.saveMeta()
3545
+ this.broadcastBranches()
3546
+ return { layout: 2, ...(shadow ? { shadow: { id: shadow.id, slug: shadow.slug || '' } } : {}), ...(kept.length ? { kept, quilt: { slug: (shadow && shadow.slug) || (this.meta.quilt && this.meta.quilt.slug) || '', id: q } } : {}) }
3547
+ }, seam, 'back')
3548
+ }
3549
+
3550
+ /**
3551
+ * A square's work, at going back, when no folder follows it: its live work committed in the square
3552
+ * as the owner (`seam`), so all of it is in Seam and readable after the square is abandoned; then
3553
+ * what changed in the square since its base and its branch document lacks written there
3554
+ * (foldBack, writeFold).
3555
+ * { branch, square, commit, copied: [path], left: [{ path, why }], uncommitted? } or null.
3556
+ */
3557
+ async foldSquareBack (seam, q, key, lk) {
3558
+ const sq = lk.slice(liveKey('').length)
3559
+ const live = this.store.load(lk)
3560
+ const e = this.store.load(key)
3561
+ if (!live || !e) return null
3562
+ const out = { branch: key, square: sq }
3563
+ let head = liveHead(live.doc)
3564
+ const row = await seam.square(q, sq)
3565
+ const list = overlay(live.doc)
3566
+ if (list.length) {
3567
+ try {
3568
+ const uploads = list.filter((x) => x.bytes).map((x) => ({ kind: 'blob', id: blobId(x.bytes), bytes: x.bytes }))
3569
+ if (uploads.length) await seam.upload(q, uploads)
3570
+ const changes = list.map((x) => x.op === 'delete' ? { path: x.path, op: 'delete' } : { path: x.path, op: 'put', blob: entryId(x), mode: x.mode })
3571
+ const r = await seam.commit(q, sq, { expect_head: (row && row.head) || null, message: `Work left in this square when session ${this.name} went back to its branches`, changes })
3572
+ seal(live.doc, { prev: head, head: r.square.head, sealed: Object.fromEntries(list.map((x) => [x.path, entryId(x)])), by: 'Quilt' })
3573
+ head = r.square.head
3574
+ } catch (err) { out.uncommitted = String(err.message || err).slice(0, 300) }
3575
+ }
3576
+ out.commit = head
3577
+ const tree = head ? await seam.tree(q, head) : null
3578
+ const square = {}
3579
+ for (const t of (tree && tree.entries) || []) square[t.path] = t.term ? { term: t.term } : { blob: t.blob || null, mode: t.mode }
3580
+ const bytes = {}
3581
+ // Not committed: the live work goes over the head as it is.
3582
+ if (out.uncommitted) for (const x of overlay(live.doc)) { if (x.op === 'delete') delete square[x.path]; else { square[x.path] = { blob: entryId(x), mode: x.mode }; if (x.bytes) bytes[entryId(x)] = x.bytes } }
3583
+ // The branch document over the square's base: what the session never syncs (links, ignored and
3584
+ // stored files) reads as it was there, so only what changed in the square since is looked at.
3585
+ const base = row && row.base ? (row.base === head ? tree : await seam.tree(q, row.base)) : tree
3586
+ const fold = foldBack(square, branchFiles(e.doc, base, { diff: isDiff(e) }))
3587
+ const need = [...new Set(fold.put.map((x) => x.blob))].filter((id) => id && !bytes[id])
3588
+ const fetch = async (ids) => { for (const [id, b64] of Object.entries((await seam.blobs(q, ids)).blobs || {})) bytes[id] = Buffer.from(b64, 'base64') }
3589
+ for (let i = 0; i < need.length; i += 100) {
3590
+ const ids = need.slice(i, i + 100)
3591
+ // One too large to read whole (413) is left, named; the others come one by one.
3592
+ try { await fetch(ids) } catch (err) { if (err.status !== 413) throw err; for (const id of ids) await fetch([id]).catch((x) => { if (x.status !== 413) throw x }) }
3593
+ }
3594
+ const w = writeFold(e, fold, bytes, 'Quilt')
3595
+ if (w.done.length) this.log(`[${this.name}] going back: copied ${w.done.length} file(s) of square ${squareShort(sq)}, which no folder follows, into ${key}`)
3596
+ return { ...out, copied: w.done, left: [...fold.skip, ...w.left] }
2353
3597
  }
2354
3598
 
3599
+ /**
3600
+ * A write to a branch document at layout 3 reached the relay: it is not kept. Its member hears
3601
+ * so (MSG_BRANCHES `readOnly`, at most once a minute a connection), with where the work goes now.
3602
+ */
3603
+ refusedReadOnly (ws, key, { binaries = [], moved = false } = {}) {
3604
+ const now = Date.now()
3605
+ if (!binaries.length && ws.readOnlyToldAt && now - ws.readOnlyToldAt < READ_ONLY_TELL_MS) return
3606
+ ws.readOnlyToldAt = now
3607
+ const q = this.meta.quilt || {}
3608
+ const live = (q.live || {})[key]
3609
+ const name = q.slug || this.meta.quilt_id || ''
3610
+ const branch = key === this.defaultKey ? 'the main branch' : key
3611
+ const movedOn = live && moved ? `This session is the quilt ${name} now: what your app wrote to ${branch}'s old copy did not go into its square ${squareShort(live.replace(/^quilt\//, ''))}, which has moved on since. Your edits stay in your folder, and go in when it follows the square.` : ''
3612
+ const why = movedOn || (live
3613
+ ? `This session is the quilt ${name} now: ${binaries.slice(0, 5).join(', ')} did not reach ${branch}'s square ${squareShort(live.replace(/^quilt\//, ''))} from the old copy; your folder takes ${binaries.length === 1 ? 'it' : 'them'} in when it follows the square.`
3614
+ : `This session is the quilt ${name} now, and ${branch} has no square yet: your edits are kept, and your app opens a square for it that your folder follows, with them.`)
3615
+ this.log(`[${this.name}] ${live && moved ? 'did not forward a write (the square moved on since its copy) to the square of' : live ? 'could not forward binaries to the square of' : 'kept a write to'} ${key}, which ${live ? 'has one' : 'has no square'}, from ${this.names.get(ws) || 'someone'}`)
3616
+ send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), readOnly: { branch: key, ...(live ? { square: live.replace(/^quilt\//, '') } : { noSquare: true }), why } }))
3617
+ }
3618
+
3619
+ /**
3620
+ * The live document of the square a branch moved on to from square `key` (quiltNext), the last of
3621
+ * a chain of moves, or null. Each was carried from the one before with its whole history
3622
+ * (carryLive), so an update to an old one applies to it as it is.
3623
+ */
3624
+ movedOnTo (key) {
3625
+ const next = this.meta.layout === LAYOUT_QUILT && this.meta.quilt && this.meta.quilt.next
3626
+ if (!next || !isLiveKey(key) || !next[key]) return null
3627
+ let to = key
3628
+ for (let i = 0; i < MOVED_ON_KEPT && next[to]; i++) to = next[to]
3629
+ return to === key ? null : this.store.load(to).doc
3630
+ }
3631
+
3632
+ /**
3633
+ * Which files `ws` may change in a late write the relay applies to a square's live document itself
3634
+ * (forwardToSquare, movedOnTo), where the guard (checkChange) never looks: none for a viewer,
3635
+ * only those inside their folders for a member limited to folders (session-access.js mayChange).
3636
+ */
3637
+ forwardRule (ws) {
3638
+ const a = this.access.get(ws)
3639
+ return (p) => this.mayWrite(a, p)
3640
+ }
3641
+
3642
+ /**
3643
+ * A write to `key`'s kept document at layout 3, from an app that has not heard of its square yet:
3644
+ * it goes into the square's live document (forwardIntoLive). A file it deleted is gone from the
3645
+ * square when the square's head has it (read through the room's read token).
3646
+ */
3647
+ forwardToSquare (ws, key, lk, update) {
3648
+ if (this.branchFull(lk)) { this.refusedReadOnly(ws, key, { binaries: ['your edits'] }); return }
3649
+ const e = this.store.load(lk)
3650
+ if (!e) return
3651
+ // Only while the square is still on the head its copy of the branch document was made over: once it
3652
+ // moved (main brought in, a commit, a rebase), the branch document's files are an older base's, and
3653
+ // a folder writing there (an app back with a stale folder) would put its older copies in as edits and
3654
+ // its missing files in as deletions (2026-11-01: 43 of them, from a folder at main:984). Not forwarded:
3655
+ // the folder's own block takes in what is its own when it follows the square (live-block.js adopt).
3656
+ if (copiedHead(e.doc) !== liveHead(e.doc)) { this.refusedReadOnly(ws, key, { moved: true }); return }
3657
+ const r = forwardIntoLive(e.doc, update, { may: this.forwardRule(ws) })
3658
+ if (r.refused.length) { this.log(`[${this.name}] did not take a write to ${r.refused.slice(0, 5).join(', ')} from ${this.names.get(ws) || 'someone'} into a square: not theirs to change`); return }
3659
+ this.log(`[${this.name}] a write to ${key} from ${this.names.get(ws) || 'someone'} went into its square ${squareShort(lk.slice(liveKey('').length))}`)
3660
+ if (r.binaries.length) this.refusedReadOnly(ws, key, { binaries: r.binaries })
3661
+ if (!r.deleted.length) return
3662
+ const head = liveHead(e.doc)
3663
+ if (!head) return
3664
+ this.quiltBlockRead({ call: 'tree', rev: head, paths: r.deleted }).then(({ tree }) => {
3665
+ const m = liveMaps(e.doc)
3666
+ const have = new Set(((tree && tree.entries) || []).map((x) => x.path))
3667
+ e.doc.transact(() => {
3668
+ for (const p of r.deleted) if (have.has(p) && !m.files.has(p) && !m.blobs.has(p)) { m.clean.delete(p); m.gone.set(p, { by: this.names.get(ws) || '', ts: Date.now() }) }
3669
+ }, 'relay-quilt')
3670
+ }, (err) => this.log(`[${this.name}] could not read ${squareShort(lk.slice(liveKey('').length))}'s head to delete ${r.deleted.join(', ')}: ${err.message}`))
3671
+ }
3672
+
3673
+ /** What an app this session can't take is told: why, and what to do (a WebSocket close reason, 123 bytes at most). */
3674
+ needsUpdate () { return this.meta.layout === LAYOUT_QUILT ? NEEDS_UPDATE_QUILT : NEEDS_UPDATE }
3675
+
2355
3676
  /** Can this connection's app handle the session as it is now? */
2356
3677
  supported (ws) {
2357
3678
  const f = ws.features || []
2358
- if (this.meta.layout === LAYOUT_QUILT && !f.includes('quilts')) return false
3679
+ // At layout 3 an app's folder must follow its square (quilt-folders): 0.3.125 sent `quilts` but kept writing the read-only branch documents.
3680
+ if (this.meta.layout === LAYOUT_QUILT && !(f.includes('quilts') && f.includes('quilt-folders'))) return false
2359
3681
  return !this.meta.largeFiles || f.includes('large-files')
2360
3682
  }
2361
3683
 
@@ -2367,7 +3689,7 @@ class Room {
2367
3689
  if (this.meta.largeFiles) return
2368
3690
  this.meta.largeFiles = true
2369
3691
  for (const ws of [...this.conns.keys(), ...this.pending.keys()]) {
2370
- if (!ws.hosted && !this.supported(ws)) { ws.endReason = 'needs_update'; ws.close(CLOSE_NEEDS_UPDATE, NEEDS_UPDATE) }
3692
+ if (!ws.hosted && !this.supported(ws)) { ws.endReason = 'needs_update'; ws.close(CLOSE_NEEDS_UPDATE, this.needsUpdate()) }
2371
3693
  }
2372
3694
  }
2373
3695
 
@@ -2981,6 +4303,18 @@ class Room {
2981
4303
  const at = (p) => this.claimKey(branch, p)
2982
4304
  const onBranch = branch === this.defaultKey ? {} : { branch }
2983
4305
  if (req.op === 'touch') return { ok: true } // an AI session is at work: its claims' idle time starts again
4306
+ // At layout 3 claims are Seam's, let go of in Seam by the member themselves (Session.releaseInSeam).
4307
+ // The ones the move made hold as the mover, so the relay says whose each was (`mine`: this
4308
+ // member's) and whether they are the owner. 'quilt-released' forgets the ones let go of.
4309
+ if (req.op === 'quilt-claims' || req.op === 'quilt-released') {
4310
+ const moved = (this.meta.layout === LAYOUT_QUILT && this.meta.quilt && this.meta.quilt.claims && this.meta.quilt.claims.moved) || []
4311
+ const minePlus = (c) => mine({ by: c.by, byId: c.byId })
4312
+ if (req.op === 'quilt-claims') return { ok: true, moved: moved.map((c) => ({ id: c.id, square: c.square, pattern: c.pattern, by: c.by, mine: minePlus(c) })), owner: !!who.owner }
4313
+ const ids = new Set([].concat(req.ids || []).map(String))
4314
+ const keep = moved.filter((c) => !(ids.has(String(c.id)) && (minePlus(c) || who.owner)))
4315
+ if (keep.length !== moved.length) { this.meta.quilt.claims.moved = keep; this.saveMeta() }
4316
+ return { ok: true, forgot: moved.length - keep.length }
4317
+ }
2984
4318
  if (req.op === 'claim') {
2985
4319
  if (!pattern) throw new Error('pattern required')
2986
4320
  if (pattern.length > MAX_PATTERN) throw new Error('pattern too long')
@@ -3149,6 +4483,8 @@ class Room {
3149
4483
  let joined = false
3150
4484
  let waiting = false
3151
4485
  ws.meter = this.meter
4486
+ // The computer's key: an app's own connection and its folder's block share it (appOf).
4487
+ ws.appKey = publicKey || ''
3152
4488
  ws.on('message', (data) => {
3153
4489
  const buf = new Uint8Array(data)
3154
4490
  this.meter.in(buf.length)
@@ -3177,7 +4513,7 @@ class Room {
3177
4513
  // Its messages are the relay's own (relay-ask.js): nobody may write as it.
3178
4514
  if (isRelay(name)) return ws.close(CLOSE_NAME_TAKEN, `"${RELAY_BY}" is the relay's own name; pick another name`)
3179
4515
  // The session may have started storing files while this app signed in.
3180
- if (!this.supported(ws)) return ws.close(CLOSE_NEEDS_UPDATE, NEEDS_UPDATE)
4516
+ if (!this.supported(ws)) return ws.close(CLOSE_NEEDS_UPDATE, this.needsUpdate())
3181
4517
  // With sign-in on, names come from accounts and can't be taken, so they aren't bound to keys.
3182
4518
  if (!account) {
3183
4519
  // Re-check: someone else may have taken the name while we waited.
@@ -3209,8 +4545,9 @@ class Room {
3209
4545
  enter (ws, a) {
3210
4546
  this.setAccess(ws, a)
3211
4547
  this.noteTurns(ws, a)
3212
- // Presence: an account's visit starts once it's let in (never while it waits for the owner).
3213
- if (ws.pass && this.presence && !ws.visit) {
4548
+ // Presence: an account's visit starts once it's let in (never while it waits for the owner). A folder's
4549
+ // live block (its second connection, on its square's live document: quilt-block) is part of the app's visit, not one of its own.
4550
+ if (ws.pass && this.presence && !ws.visit && !(ws.features || []).includes('quilt-block')) {
3214
4551
  ws.visit = this.presence.visitStart({ room: this.name, account: `${ws.pass.kind}:${ws.pass.sub}`, name: a.name, owner: !!a.owner, via: 'app', tool: ws.tool || '' })
3215
4552
  // The accounts API learns who owns a session from this report, and only the owner may
3216
4553
  // give people access there: tell it now rather than within the minute.
@@ -3233,10 +4570,12 @@ class Room {
3233
4570
  join (ws, name) {
3234
4571
  this.conns.set(ws, new Set())
3235
4572
  this.names.set(ws, name)
3236
- send(ws, syncStep1Message(this.doc))
4573
+ // A folder's live block (quilt-block) syncs its square's live document only: its app's own connection has the room's.
4574
+ ws.roomless = (ws.features || []).includes('quilt-block')
4575
+ if (!ws.roomless) send(ws, syncStep1Message(this.doc))
3237
4576
  send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields() }))
3238
4577
  const states = [...this.awareness.getStates().keys()]
3239
- if (states.length) send(ws, awarenessMessage(this.awareness, states))
4578
+ if (states.length && !ws.roomless) send(ws, awarenessMessage(this.awareness, states))
3240
4579
  send(ws, jsonMessage(MSG_CLAIMS, { claims: this.claimList(ws.branch) }))
3241
4580
  this.touch()
3242
4581
  }
@@ -3299,6 +4638,9 @@ class Room {
3299
4638
  if (type === MSG_SYNC) {
3300
4639
  const docId = decoding.readVarString(dec)
3301
4640
  let doc = this.doc
4641
+ // A folder's live block (quilt-block) touches its square only: it may read the room's document
4642
+ // (its connection's sync asks), never write chat, tasks or activity there (it has no visit of its own).
4643
+ if (docId === ROOM_DOC && ws.roomless && decoding.peekVarUint(dec) !== syncProtocol.messageYjsSyncStep1) return
3302
4644
  if (docId !== ROOM_DOC) {
3303
4645
  // A connection syncs the room's document and the one branch it joined, nothing else.
3304
4646
  // An edit it sent for the branch it just left, still in flight when the join landed
@@ -3310,8 +4652,20 @@ class Room {
3310
4652
  // A branch over its size limit takes nothing new; the room and every other branch carry
3311
4653
  // on, and nobody is disconnected for it (its members hear it in the branch list).
3312
4654
  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
4655
+ // Live sync stopped at the forced date (checkForcedDate): the branch documents stay as they were then. Read, never written.
4656
+ if (this.meta.syncStopped && !isLiveKey(k) && decoding.peekVarUint(dec) !== syncProtocol.messageYjsSyncStep1) { this.refusedSyncStopped(ws); return }
4657
+ // At layout 3 the work is in the squares (quiltSwitch); the branch documents are what going
4658
+ // back returns to. A write that still reaches one (an app that has not heard of its square
4659
+ // yet, or one back with edits made offline) goes into the branch's square, and stays in the
4660
+ // document too, so it is there on going back and every app's copy of it agrees with the
4661
+ // relay's. A branch with no square keeps it, and its member is told, so their app opens one
4662
+ // (quiltOpen copies the document, with it). Never dropped.
4663
+ if (this.meta.layout === LAYOUT_QUILT && !isLiveKey(k) && decoding.peekVarUint(dec) !== syncProtocol.messageYjsSyncStep1) {
4664
+ let update = null
4665
+ try { const d = decoding.clone(dec); decoding.readVarUint(d); update = decoding.readVarUint8Array(d) } catch {}
4666
+ const live = (this.meta.quilt && this.meta.quilt.live || {})[k]
4667
+ if (update && bringsNew(doc, update)) { if (live) this.forwardToSquare(ws, k, live, update); else this.refusedReadOnly(ws, k) }
4668
+ }
3315
4669
  }
3316
4670
  if (docId === ROOM_DOC ? this.full : this.unsavable) {
3317
4671
  // Over quota: still answer "what do you have?" so people can read, but refuse new data.
@@ -3323,16 +4677,54 @@ class Room {
3323
4677
  }
3324
4678
  const enc = syncHeader(docId)
3325
4679
  const header = encoding.length(enc)
3326
- syncProtocol.readSyncMessage(dec, enc, doc, ws)
4680
+ // A square its branch moved on from (quiltNext): what still reaches it goes on to the square the branch is on now.
4681
+ const onto = docId === ROOM_DOC ? null : this.movedOnTo(this.resolveKey(docId))
4682
+ const took = []
4683
+ const take = (u) => took.push(u)
4684
+ if (onto) doc.on('update', take)
4685
+ try { syncProtocol.readSyncMessage(dec, enc, doc, ws) } finally { if (onto) doc.off('update', take) }
4686
+ for (const u of took) applyChecked(onto, u, this.forwardRule(ws), 'relay-quilt')
3327
4687
  if (encoding.length(enc) > header) send(ws, encoding.toUint8Array(enc))
3328
4688
  } else if (type === MSG_BRANCH) {
3329
4689
  let req = {}
3330
4690
  let reply
3331
4691
  try {
3332
4692
  req = JSON.parse(decoding.readVarString(dec))
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) }
4693
+ if (req.op === 'quilt-migrate' || req.op === 'quilt-make' || req.op === 'quilt-switch' || req.op === 'quilt-shadow' || req.op === 'quilt-unswitch' || req.op === 'quilt-next' || req.op === 'quilt-open' || req.op === 'quilt-catch-up') { this.quiltMigrateRequest(ws, req); return }
4694
+ // A folder's live block whose square's head Seam moved (seam_rebase, a settled conflict): its live document follows, combined (bring-in.js moveLive).
4695
+ if (req.op === 'live-move') {
4696
+ const answer = (fields) => send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), reply: { id: req.id, op: req.op, ...fields } }))
4697
+ this.liveMove(ws, req).then((r) => answer({ ok: true, ...r }), (err) => answer({ ok: false, error: err.message }))
4698
+ return
4699
+ }
4700
+ // quilt_history from a folder with no Seam sign-in of its own: the quilt's log, read with the
4701
+ // room's read token (quiltReadClient). Any member may: the log is the session's own code.
4702
+ if (req.op === 'quilt-log') {
4703
+ const answer = (fields) => send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), reply: { id: req.id, op: req.op, ...fields } }))
4704
+ this.quiltLog(req.query && typeof req.query === 'object' ? req.query : {}).then(answer, (err) => answer({ ok: false, error: err.message }))
4705
+ return
4706
+ }
4707
+ // A member's folder that follows its square in place (session-follow.js) reads the square
4708
+ // through the relay with the same room read token, so no member needs Seam set up to follow.
4709
+ if (req.op === 'quilt-block') {
4710
+ const answer = (fields) => send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), reply: { id: req.id, op: req.op, ...fields } }))
4711
+ const a = this.access.get(ws)
4712
+ if (!a || (a.state && a.state !== 'approved')) return answer({ ok: false, error: 'not in this session' })
4713
+ // A slow or failed read is on the relay's log: a folder waiting to follow its square says only that it waits.
4714
+ const t0 = Date.now()
4715
+ this.quiltBlockRead(req).then((r) => { if (Date.now() - t0 > 5000) this.log(`[${this.name}] quilt-block ${req.call} took ${Date.now() - t0} ms`); answer({ ok: true, ...r }) }, (err) => { this.log(`[${this.name}] quilt-block ${req.call} failed after ${Date.now() - t0} ms: ${err.status ? `${err.status} ` : ''}${err.message}`); answer({ ok: false, error: err.message }) })
4716
+ return
4717
+ }
4718
+ // The app's History, Blame and search of a file in a linked session: the same room read token.
4719
+ if (req.op === 'quilt-blame' || req.op === 'quilt-search' || req.op === 'quilt-show') {
4720
+ const answer = (fields) => send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), reply: { id: req.id, op: req.op, ...fields } }))
4721
+ const a = this.access.get(ws)
4722
+ if (!a || (a.state && a.state !== 'approved')) return answer({ ok: false, error: 'not in this session' })
4723
+ const read = req.op === 'quilt-blame' ? this.quiltBlame(req.path) : req.op === 'quilt-search' ? this.quiltSearch(req.q) : this.quiltShow(req.commit, req.path)
4724
+ read.then(answer, (err) => answer({ ok: false, error: err.message }))
4725
+ return
4726
+ }
4727
+ reply = { id: req.id, op: req.op, ...this.branchRequest(ws, req) }
3336
4728
  } catch (err) {
3337
4729
  reply = { id: req.id, op: req.op, ok: false, error: err.message }
3338
4730
  }
@@ -4285,6 +5677,23 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4285
5677
  json(200, { ok: true, id: msg.id })
4286
5678
  })
4287
5679
  }
5680
+ // The website dashboard's summary of now (now-summary.js), for the accounts API (GET /v1/me/now):
5681
+ // { account, rooms } → what is going on for that person in each of those sessions loaded here. A room
5682
+ // that isn't loaded has nobody in it, so it is left out; nothing is loaded just to answer.
5683
+ if (url.pathname === '/now' && req.method === 'POST') {
5684
+ if (!fromApi(req)) return json(401, { error: 'only the accounts API may ask this' })
5685
+ return readJson(req, 8 * 1024, (err, b) => {
5686
+ const account = !err && b && typeof b.account === 'string' ? b.account : ''
5687
+ if (!/^person:[A-Za-z0-9_-]{1,64}$/.test(account) || !Array.isArray(b.rooms)) return json(400, { error: 'give the account and its rooms' })
5688
+ const sessions = []
5689
+ for (const name of b.rooms.slice(0, 60)) {
5690
+ const room = typeof name === 'string' && ROOM_RE.test(name) ? rooms.get(name) : null
5691
+ if (!room || !room.exists) continue
5692
+ try { const one = nowSummary(room, account, { detail: b.detail === true && b.rooms.length === 1 }); if (one) sessions.push(one) } catch (e) { log(`now: ${name}: ${e.message}`) }
5693
+ }
5694
+ json(200, { sessions })
5695
+ })
5696
+ }
4288
5697
  // Someone connected GitHub (the API's callback): every session they are in checks its repository again now.
4289
5698
  if (url.pathname === '/github/connected' && req.method === 'POST') {
4290
5699
  const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
@@ -4325,14 +5734,20 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4325
5734
  return json(200, { ok: true, plan, text: describeQuiltPlan(plan) })
4326
5735
  } catch (e) { return json(409, { error: e.message }) }
4327
5736
  }
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
5737
  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
5738
  const seam = (room.seamClient || quiltClient)({ api, token: b.token })
5739
+ if (b.step === 'unswitch') {
5740
+ return room.quiltUnswitch(access, seam, { carried: Array.isArray(b.carried) ? b.carried.slice(0, 200) : [] }).then((r) => json(200, { ok: true, ...r }), (e) => json(409, { error: e.message, ...(e.open ? { open: e.open } : {}) }))
5741
+ }
4333
5742
  if (b.step === 'switch') {
4334
5743
  return room.quiltSwitch(access, seam).then((r) => json(200, { ok: true, ...r }), (e) => json(409, { error: e.message }))
4335
5744
  }
5745
+ if (b.step === 'make') {
5746
+ return room.quiltMake(access, seam).then((quilt) => json(200, { ok: true, quilt }), (e) => json(409, { error: e.message, ...(e.quilt_id ? { quilt_id: e.quilt_id } : {}), ...(e.steps ? { steps: e.steps } : {}) }))
5747
+ }
5748
+ if (b.step === 'shadow') {
5749
+ return room.quiltShadowLink(access, seam, b.quilt ?? null).then((r) => json(200, { ok: true, ...r }), (e) => json(409, { error: e.message }))
5750
+ }
4336
5751
  room.quiltMigrate(access, seam).then(
4337
5752
  (quilt) => json(200, { ok: true, quilt }),
4338
5753
  (e) => json(409, { error: e.message, ...(e.quilt_id ? { quilt_id: e.quilt_id } : {}) })
@@ -4661,12 +6076,12 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4661
6076
  // People and agents come from different id spaces, so the kind is part of who they are.
4662
6077
  const account = pass ? `${pass.kind}:${pass.sub}` : ''
4663
6078
  // Checked before the room is touched: a room's creator is recorded by this key (see authorize).
4664
- if (!publicKey) return reject(socket, 400, 'This relay needs a newer quilt; please update')
6079
+ if (!publicKey) return reject(socket, 400, withUpdateSteps(NEEDS_UPDATE, { ascii: true }))
4665
6080
  const key = parsePublicKey(publicKey)
4666
6081
  if (!person || person.length > MAX_NAME || !key) return reject(socket, 400, 'Bad name or identity key')
4667
6082
  // Every session keeps its files in branch documents: an app that can't sync them is told to update.
4668
6083
  const features = String(url.searchParams.get('features') || '').split(',')
4669
- if (!features.includes('branches')) return reject(socket, 400, NEEDS_UPDATE)
6084
+ if (!features.includes('branches')) return reject(socket, 400, withUpdateSteps(NEEDS_UPDATE, { ascii: true }))
4670
6085
  if (roomEnded(name)) return reject(socket, 410, ENDED_MESSAGE)
4671
6086
  const ip = clientIp(req)
4672
6087
  const starter = pass ? account : ip
@@ -4682,9 +6097,10 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4682
6097
  return reject(socket, auth === 'need-key' ? 403 : 401, auth === 'need-key' ? 'Relay key required to create rooms' : 'Wrong room secret')
4683
6098
  }
4684
6099
  // Checked after the secret, so only members learn what the session needs.
4685
- if ((room.meta.largeFiles && !features.includes('large-files')) || (room.meta.layout === LAYOUT_QUILT && !features.includes('quilts'))) {
6100
+ const noQuilt = room.meta.layout === LAYOUT_QUILT && !(features.includes('quilts') && features.includes('quilt-folders'))
6101
+ if ((room.meta.largeFiles && !features.includes('large-files')) || noQuilt) {
4686
6102
  if (!room.conns.size && room.onEmpty) room.onEmpty() // don't keep it in memory for nobody
4687
- return reject(socket, 400, NEEDS_UPDATE)
6103
+ return reject(socket, 400, withUpdateSteps(noQuilt ? NEEDS_UPDATE_QUILT : NEEDS_UPDATE, { ascii: true }))
4688
6104
  }
4689
6105
  if (!pass && !room.keyMatches(person, publicKey)) return reject(socket, 403, nameTaken(person))
4690
6106
  wss.handleUpgrade(req, socket, head, (ws) => {
@@ -4782,7 +6198,8 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4782
6198
  upstreamTicker?.unref()
4783
6199
  // Live-sync leases renewed, and let go of for sessions that ended or sat idle for a day (relay-lease.js).
4784
6200
  const leaseTicker = cfg.gitSync ? setInterval(() => {
4785
- leases.tick({ ended: (name) => { const r = rooms.get(name); return !!(r && r.ended) } }).then((lost) => {
6201
+ // A migration's lease (Room.withMigrationLease) is let go of once no migration here holds it (after a crash, at the next tick).
6202
+ leases.tick({ ended: (name) => { if (name.startsWith(MIGRATION_LEASE)) return !migrating.has(name); const r = rooms.get(name); return !!(r && r.ended) } }).then((lost) => {
4786
6203
  for (const l of lost) { const r = rooms.get(l.room); if (r && !r.ended) { r.log(`[${r.name}] lost the live-sync lease of ${l.branch} to ${l.to || 'nobody (lapsed)'}`); r.broadcastBranches() } }
4787
6204
  }).catch((err) => log(`lease tick: ${err.message}`))
4788
6205
  }, cfg.leaseHeartbeatMs || LEASE_HEARTBEAT_MS) : null
@@ -4974,11 +6391,25 @@ function receiveBlob (req, file, limit, done) {
4974
6391
  const TOO_BIG = 'Session over the size limit'
4975
6392
  const UNREADABLE = "This session's data can't be read on the relay right now"
4976
6393
  const ENDED_MESSAGE = 'The owner ended this session'
4977
- const NEEDS_UPDATE = 'This session needs a newer version of Quilt. Update Quilt, then join again.'
6394
+ const NEEDS_UPDATE = needsUpdateWords()
6395
+ // How many of a branch's moves to its next square the relay remembers, to send a late edit on (movedOnTo).
6396
+ const MOVED_ON_KEPT = 50
6397
+ // A session whose code is in Seam (layout 3) takes apps whose folders follow their squares (quilt-folders, 0.3.127).
6398
+ // Only what to do: an AI told the session "moved into Seam" took Seam for a way to join it.
6399
+ const NEEDS_UPDATE_QUILT = needsUpdateWords('0.3.127')
6400
+ // How many times a move copies again the branches edited while it waited on Seam before it gives up (quiltSwitchNow).
6401
+ const MOVE_CATCH_UP_ROUNDS = 5
6402
+ // How often a member is told again that a write to a read-only branch document was not kept (refusedReadOnly).
6403
+ const READ_ONLY_TELL_MS = 60 * 1000
6404
+ // A room's read token with less than this left is made again at a move, or when a branch moves on to its next square (Room.freshReadToken).
6405
+ const READ_TOKEN_RENEW_MS = 30 * 86400e3
4978
6406
  const SIGN_IN = 'Update Quilt and sign in to continue'
4979
6407
  const NOT_LET_IN = "The session owner hasn't let you in yet."
4980
6408
  const PASS_EXPIRED = 'Your sign-in expired. Reconnecting.'
4981
6409
 
6410
+ /** One app on one computer, as its connections say (an app's own and its folder's block share both). */
6411
+ const appOf = (ws, name) => `${ws.appKey || ''}\n${name || ''}`
6412
+
4982
6413
  function reject (socket, code, message) {
4983
6414
  socket.write(`HTTP/1.1 ${code} ${message}\r\nConnection: close\r\n\r\n`)
4984
6415
  socket.destroy()