heyquilt 0.3.121 → 0.3.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/RELEASES.md +292 -17
  2. package/bin/quilt.js +72 -5
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -1
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +21 -10
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/seam-connect.js +103 -0
  15. package/src/api/routes/session-migrate.js +39 -0
  16. package/src/api/routes/sessions.js +73 -1
  17. package/src/api/server.js +46 -12
  18. package/src/api/supabase-store.js +53 -4
  19. package/src/autowork.js +182 -16
  20. package/src/connection.js +39 -9
  21. package/src/control.js +139 -13
  22. package/src/force-date.js +27 -0
  23. package/src/fsutil.js +13 -3
  24. package/src/gitstate.js +38 -2
  25. package/src/history.js +25 -1
  26. package/src/mcp.js +93 -7
  27. package/src/merge3.js +11 -3
  28. package/src/now-summary.js +113 -0
  29. package/src/pathrules.js +12 -0
  30. package/src/protocol.js +10 -2
  31. package/src/quilt-default.js +84 -0
  32. package/src/quilt-history.js +106 -0
  33. package/src/quilt-remote.js +78 -0
  34. package/src/relay-commit.js +5 -0
  35. package/src/relay-lease.js +6 -2
  36. package/src/relay-mcp.js +172 -8
  37. package/src/relay-outbound.js +1 -1
  38. package/src/relay-prune.js +4 -1
  39. package/src/release-words.js +2 -2
  40. package/src/runner.js +20 -7
  41. package/src/seam-access.js +97 -0
  42. package/src/seam-claims.js +53 -0
  43. package/src/seam-url.js +15 -0
  44. package/src/server.js +1767 -33
  45. package/src/session-follow.js +515 -0
  46. package/src/session-layout3.js +466 -0
  47. package/src/session-migrate.js +123 -0
  48. package/src/session-quilt.js +145 -0
  49. package/src/session-seam.js +284 -0
  50. package/src/session.js +899 -46
  51. package/src/source-live/bring-in.js +283 -0
  52. package/src/source-live/live-block.js +507 -35
  53. package/src/source-live/live-square.js +17 -6
  54. package/src/status.js +5 -0
  55. package/src/ui/agent-guide.js +1 -1
  56. package/src/ui/app.css +85 -20
  57. package/src/ui/app.js +4 -1
  58. package/src/ui/chat.js +9 -3
  59. package/src/ui/cloud-ai-model.js +13 -0
  60. package/src/ui/cloud-ai.js +10 -2
  61. package/src/ui/common.js +6 -2
  62. package/src/ui/fileview.js +20 -7
  63. package/src/ui/force-words.js +19 -0
  64. package/src/ui/git-rule.js +9 -0
  65. package/src/ui/home.js +0 -147
  66. package/src/ui/loom-model.js +15 -1
  67. package/src/ui/quiltview.js +213 -0
  68. package/src/ui/seam-model.js +251 -0
  69. package/src/ui/session.js +413 -31
  70. package/src/ui/voice-call.js +83 -14
  71. package/src/ui/voice-model.js +39 -0
  72. package/src/ui/voice.js +167 -17
  73. package/src/ui-server.js +80 -28
  74. package/src/update-check.js +25 -1
  75. package/src/voice.js +13 -6
  76. package/src/git.js +0 -147
  77. package/src/quilts/AGENTS.md +0 -1
  78. package/src/quilts/CLAUDE.md +0 -1
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/claims.js +0 -144
  87. package/src/quilts/cli.js +0 -449
  88. package/src/quilts/client.js +0 -128
  89. package/src/quilts/conflict-format.js +0 -190
  90. package/src/quilts/describe.js +0 -266
  91. package/src/quilts/dogfood.js +0 -82
  92. package/src/quilts/errors.js +0 -8
  93. package/src/quilts/events.js +0 -160
  94. package/src/quilts/export-git.js +0 -130
  95. package/src/quilts/export.js +0 -81
  96. package/src/quilts/fastmerge.js +0 -41
  97. package/src/quilts/hash.js +0 -44
  98. package/src/quilts/history.js +0 -266
  99. package/src/quilts/ids.js +0 -12
  100. package/src/quilts/import-git.js +0 -291
  101. package/src/quilts/import.js +0 -134
  102. package/src/quilts/index.js +0 -72
  103. package/src/quilts/limits.js +0 -67
  104. package/src/quilts/lines.js +0 -49
  105. package/src/quilts/live.js +0 -50
  106. package/src/quilts/merge-smart.js +0 -114
  107. package/src/quilts/merge.js +0 -486
  108. package/src/quilts/objects.js +0 -144
  109. package/src/quilts/pack.js +0 -118
  110. package/src/quilts/records.js +0 -211
  111. package/src/quilts/resolutions.js +0 -69
  112. package/src/quilts/reviews.js +0 -195
  113. package/src/quilts/routes.js +0 -126
  114. package/src/quilts/service.js +0 -923
  115. package/src/quilts/setup.js +0 -34
  116. package/src/quilts/signing.js +0 -149
  117. package/src/quilts/squares.js +0 -239
  118. package/src/quilts/store-memory.js +0 -683
  119. package/src/quilts/store-rpc.js +0 -79
  120. package/src/quilts/store-supabase.js +0 -13
  121. package/src/quilts/tags.js +0 -64
  122. package/src/quilts/textdiff.js +0 -96
  123. package/src/quilts/tokens.js +0 -66
  124. package/src/quilts/tools.js +0 -375
  125. package/src/quilts/tree.js +0 -351
  126. package/src/quilts/webhook.js +0 -116
  127. package/src/quilts/window.js +0 -119
  128. package/src/quilts/xdiff.js +0 -435
  129. package/src/ui/branches.js +0 -111
package/src/server.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // editors change files, viewers only watch and chat, and agents can be
15
15
  // limited to some folders. The relay enforces it by undoing file changes a
16
16
  // member isn't allowed to make, before anyone else sees them.
17
+ import { quiltOf, gitTokenRefusal } from './session-seam.js'
17
18
  import http from 'node:http'
18
19
  import { makeChatLink, extendChatLink, pruneChatLinks, handleChatLink, fetchPublicFile } from './chat-links.js'
19
20
  import fs from 'node:fs'
@@ -25,7 +26,7 @@ import { WebSocketServer } from 'ws'
25
26
  import * as Y from 'yjs'
26
27
  import { handleAgentMcp, handleHostedMcp, watchFeatures } from './relay-mcp.js'
27
28
  import { dropCheckpoints } from './relay-turn-sync.js'
28
- import { UpdateCheck } from './update-check.js'
29
+ import { UpdateCheck, needsUpdateWords, withUpdateSteps } from './update-check.js'
29
30
  import {
30
31
  MSG_SYNC, MSG_AWARENESS, MSG_QUERY_AWARENESS, MSG_AUTH, MSG_CLAIM, MSG_CLAIMS, MAX_SHARED_FILE_BYTES,
31
32
  MSG_ACCESS, MSG_ADMIN, MSG_MEMBERS, MSG_PASS, MSG_BRANCH, MSG_BRANCHES,
@@ -34,7 +35,22 @@ import {
34
35
  ROOM_DOC, syncHeader,
35
36
  syncStep1Message, updateMessage, awarenessMessage, bytesMessage, jsonMessage
36
37
  } from './protocol.js'
37
- import { isLiveKey, liveRequest } from './source-live/live-square.js'
38
+ import { isLiveKey, liveRequest, liveKey, liveMaps, 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'
41
+ import { planSessionQuilt, quiltPlanRefusal, describeQuiltPlan, fileHash } from './session-quilt.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'
49
+ import { readMerges } from './merges.js'
50
+ import { readTasks, updateTask } from './tasks.js'
51
+ import { addComment } from './task-comments.js'
52
+ // Seam over HTTP through its client package (@heyquilt/seam), never Seam's code.
53
+ import { quiltClient } from '@heyquilt/seam/lib/quilts/client.js'
38
54
  import { BranchStore, DEFAULT_KEY, BRANCH_IDLE_MS, DETACHED_TTL_MS, MAX_BRANCHES, validBranchKey, covers, splitLegacyDoc } from './branchdocs.js'
39
55
  import { parsePublicKey, verifyChallenge } from './identity.js'
40
56
  import { verifyPass, PASS_TTL_MS } from './passes.js'
@@ -54,7 +70,7 @@ import { noteCloudAi, cloudAiWaker } from './relay-ai-vm.js'
54
70
  import { normalizeHosted, hostedIn, newHosted } from './hosted-agents.js'
55
71
  import { ChatArchive } from './chat-archive.js'
56
72
  import { asksRelay, relayAnswer, mergedBranches } from './relay-ask.js'
57
- 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'
58
74
  import { inactiveBranches, mergedByAncestry, mergedByPull, samePatches, deleteOnGithub, pruneNotice, ARCHIVE_KEEP_MS, ANCESTRY_EVERY_MS } from './relay-prune.js'
59
75
  import { githubReader } from './relay-upstream.js'
60
76
  import { RELAY_BY, isRelay } from './branches.js'
@@ -74,6 +90,8 @@ import { clashAction, noteMergeChecks } from './relay-clash.js'
74
90
  import { AGENT_COMMITS } from './github-commit.js'
75
91
  import { repoMoves } from './relay-repo-moves.js'
76
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'
77
95
 
78
96
  const ROOM_RE = /^[A-Za-z0-9_-]{1,64}$/
79
97
  const MAX_NAME = 64
@@ -111,6 +129,17 @@ const CLOUD_AI_GONE_MS = 5 * 60 * 1000
111
129
  const BRANCH_SEEN_SAVE_MS = 60 * 60 * 1000
112
130
  // A message to the relay older than this when it reaches the relay gets no answer (answerRelay).
113
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
+ }
114
143
  const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
115
144
  const LOGO = path.join(ROOT, 'assets', 'logo.svg')
116
145
  const hash = (s) => crypto.createHash('sha256').update(String(s)).digest()
@@ -213,6 +242,12 @@ function loadedFor (stack) {
213
242
  return frames.slice(0, 2).map((l) => l.replace(/\(?file:\/\/\/?[^)]*\/src\//, '(').replace(/:\d+\)?$/, ')')).join(' < ') || 'unknown'
214
243
  }
215
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
+
216
251
  class Room {
217
252
  constructor (name, dataDir, cfg, log) {
218
253
  this.name = name
@@ -264,6 +299,7 @@ class Room {
264
299
  })
265
300
  // A room saved before branch documents: its one document becomes the default branch's.
266
301
  this.migrateLegacy()
302
+ this.recoverQuiltMove()
267
303
  this.pruneBranches(Date.now(), { quiet: true }) // at load; the sweep says it in chat
268
304
  this.awareness = new awarenessProtocol.Awareness(this.doc)
269
305
  this.awareness.setLocalState(null)
@@ -344,7 +380,7 @@ class Room {
344
380
  if (this.cloudAiWaker) this.cloudAiWaker.personActive(this, origin).catch(() => {}) // their cloud AI wakes while they work
345
381
  }
346
382
  const msg = updateMessage(update)
347
- 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)
348
384
  this.bytes += update.length
349
385
  if (!this.full && this.bytes > cfg.maxRoomBytes) {
350
386
  this.full = true
@@ -361,7 +397,7 @@ class Room {
361
397
  for (const id of removed) ids.delete(id)
362
398
  }
363
399
  const msg = awarenessMessage(this.awareness, changed)
364
- for (const ws of this.conns.keys()) send(ws, msg)
400
+ for (const ws of this.conns.keys()) if (!ws.roomless) send(ws, msg)
365
401
  // Which upstream each branch follows, and where its folders are (relay-upstream.js).
366
402
  if (origin && this.conns.has(origin)) this.noteUpstreams()
367
403
  })
@@ -979,7 +1015,11 @@ class Room {
979
1015
  onBranch (ws, rel) {
980
1016
  const e = ws.branch ? this.store.get(ws.branch) : null
981
1017
  // A diff branch leaves its unchanged files to git: a path it hasn't deleted counts as on it.
982
- 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))
983
1023
  }
984
1024
 
985
1025
  /** Whether a new branch here may be a diff one: the relay keeps a git mirror of the session's GitHub repository. */
@@ -1026,7 +1066,7 @@ class Room {
1026
1066
  * are never only in memory, and a crash part-way is finished at the next load.
1027
1067
  */
1028
1068
  migrateLegacy () {
1029
- if (this.meta.layout === 2) return
1069
+ if (this.meta.layout === 2 || this.meta.layout === LAYOUT_QUILT) return
1030
1070
  const legacy = this.doc
1031
1071
  if (this.docFile && (legacy.getMap('files').size || legacy.getMap('blobs').size)) {
1032
1072
  const stored = [...legacy.getMap('blobs').values()].filter((x) => x && x.stored && x.stored.id).map((x) => x.stored.id).sort()
@@ -1384,6 +1424,7 @@ class Room {
1384
1424
 
1385
1425
  /** A room whose move to branch documents failed on the relay's disk (migrateLegacy) stays read-only: no branch document is used. */
1386
1426
  assertMigrated () {
1427
+ if (this.meta.layout === LAYOUT_QUILT) throw new Error(`This session is the quilt ${(this.meta.quilt && this.meta.quilt.slug) || this.meta.quilt_id} now: its files are in its squares, not in branches. Use Seam (squares, commits, merges).`)
1387
1428
  if (this.meta.layout !== 2) throw new Error("The relay couldn't finish updating how it stores this session (its disk refused), so the session is read-only until the relay is fixed.")
1388
1429
  }
1389
1430
 
@@ -1499,7 +1540,8 @@ class Room {
1499
1540
  checkUpstreams ({ force = false, keys = null } = {}) {
1500
1541
  // A look asked for (force) waits for one under way, then has its own: the one under way may not include its branches.
1501
1542
  if (this.upstreamRun) return force ? this.upstreamRun.catch(() => {}).then(() => this.checkUpstreams({ force, keys })) : this.upstreamRun
1502
- 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([])
1503
1545
  this.noteConnections({ keys }) // the connections, about once an hour per branch, with the relay's own looks
1504
1546
  const now = Date.now()
1505
1547
  // With the mirror on: about every minute (git ls-remote, no API quota); webhooks in between (addendum 16.13).
@@ -1811,8 +1853,60 @@ class Room {
1811
1853
  if (this.cfg.upstreamAuto) this.checkUpstreams().catch(() => {})
1812
1854
  }
1813
1855
 
1856
+ /**
1857
+ * What every branch-list message carries: the list and, once the session is a quilt (behind
1858
+ * QUILT_SOURCE_URL), its link { id, slug, squares: { branch: square id } } so each member's
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.
1861
+ */
1862
+ branchesFields () {
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() }))
1906
+ }
1907
+
1814
1908
  broadcastBranches () {
1815
- const msg = jsonMessage(MSG_BRANCHES, { branches: this.branchList() })
1909
+ const msg = jsonMessage(MSG_BRANCHES, { ...this.branchesFields() })
1816
1910
  for (const ws of this.conns.keys()) send(ws, msg)
1817
1911
  }
1818
1912
 
@@ -1838,7 +1932,7 @@ class Room {
1838
1932
 
1839
1933
  /** The relay pushed `sha` to branch `key`'s upstream: folders on it fetch now and follow it (no file is written: the session has it). */
1840
1934
  tellPushed (key, sha) {
1841
- const msg = jsonMessage(MSG_BRANCHES, { branches: this.branchList(), pushed: { branch: key, sha } })
1935
+ const msg = jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), pushed: { branch: key, sha } })
1842
1936
  for (const ws of this.conns.keys()) send(ws, msg)
1843
1937
  }
1844
1938
 
@@ -1961,7 +2055,7 @@ class Room {
1961
2055
  if (head && sync && sync.pushed === head && sync.base === head) { echo = true; this.saveMeta(); continue } // our own push, come back round
1962
2056
  const at = this.cfg.gitSync && this.mirrors && this.meta.sync && this.meta.sync[key] && this.meta.sync[key].base ? this.meta.sync[key].base : rec.sha
1963
2057
  if (head && at === head) { this.saveMeta(); continue } // already there
1964
- const msg = jsonMessage(MSG_BRANCHES, { branches: this.branchList(), pushed: { branch: key, sha: head, ...(forced ? { forced: true } : {}) } })
2058
+ const msg = jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), pushed: { branch: key, sha: head, ...(forced ? { forced: true } : {}) } })
1965
2059
  for (const ws of this.conns.keys()) send(ws, msg)
1966
2060
  this.checkUpstreamsNow([key]).catch((err) => this.log(`[${this.name}] could not look for new commits: ${err.message}`))
1967
2061
  }
@@ -2017,14 +2111,26 @@ class Room {
2017
2111
  * app asks before it clears its folder for a switch).
2018
2112
  */
2019
2113
  branchRequest (ws, req) {
2114
+ // Seam, behind QUILT_SOURCE_URL (Seam's plans/quilt-source-control/16): what making this session a
2115
+ // quilt would do, read only. The owner's (or, in a session without one, any editor's).
2116
+ if (req.op === 'quilt-plan') {
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')
2118
+ return { ok: true, plan: this.quiltPlan(this.access.get(ws), req.tip) }
2119
+ }
2020
2120
  const key = String(req.branch ?? '')
2021
2121
  if (!validBranchKey(key)) throw new Error(`"${key.slice(0, 60)}" isn't a branch name`)
2022
2122
  if (req.op === 'join') {
2023
- this.assertMigrated()
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.
2127
+ const square = this.meta.layout === LAYOUT_QUILT && isLiveKey(key)
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()
2024
2130
  // An app from before branch documents names the branch the old document was; else the first real branch takes ∅.
2025
2131
  let adopted = false
2026
- if (validBranchKey(String(req.adopt || ''))) adopted = this.adoptDefault(String(req.adopt))
2027
- 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
2028
2134
  const k = this.resolveKey(key)
2029
2135
  const created = !this.meta.branches[k]
2030
2136
  if (created) {
@@ -2074,6 +2180,16 @@ class Room {
2074
2180
  if (!covers(Y.encodeStateVector(e.doc), want)) throw new Error(`not all of your changes on ${key} have reached the relay yet`)
2075
2181
  return { ok: true, branch: k }
2076
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
+ }
2077
2193
  if (req.op === 'remove') {
2078
2194
  const a = this.access.get(ws)
2079
2195
  if (!a || (this.controlled && !a.owner)) throw new Error('only the session owner can remove a branch')
@@ -2085,8 +2201,8 @@ class Room {
2085
2201
  this.removeBranch(k)
2086
2202
  return { ok: true, branch: k, listChanged: true }
2087
2203
  }
2088
- // Quilt source control (src/source-live/live-square.js), behind QUILT_SOURCE: a square's live document's own ops.
2089
- 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)) {
2090
2206
  const k = this.resolveKey(key)
2091
2207
  const e = k === ws.branch ? this.store.get(k) : null
2092
2208
  if (!e) throw new Error(`you are not on ${key}`)
@@ -2095,9 +2211,1474 @@ class Room {
2095
2211
  throw new Error('unknown branch request')
2096
2212
  }
2097
2213
 
2214
+ /**
2215
+ * Tasks with a pull request, once the room is a quilt (16's table): one whose branch became a
2216
+ * square is kept in `meta.quilt.tasks` (task id -> square id); one whose branch did not loses
2217
+ * its `pr`, kept as a task comment. Returns the task id -> square id map.
2218
+ */
2219
+ quiltTasks (squares) {
2220
+ const tasks = readTasks(this.tasks)
2221
+ const out = {}
2222
+ for (const t of tasks) {
2223
+ if (!t.pr) continue
2224
+ const sq = t.pr.branch && squares[t.pr.branch]
2225
+ if (sq) { out[t.id] = sq; continue }
2226
+ const which = `${t.pr.number ? ` #${t.pr.number}` : ''}${t.pr.url ? ` (${t.pr.url})` : ''}`
2227
+ updateTask(this.doc, this.tasks, { id: t.id, pr: null }, 'quilt-migrate')
2228
+ addComment(this.doc, this.taskComments, tasks, { taskId: t.id, by: 'Quilt', text: `This task's pull request${which} did not carry over when the session became a quilt: no square was made from its branch${t.pr.branch ? ` ${t.pr.branch}` : ''}.` }, 'quilt-migrate')
2229
+ }
2230
+ return out
2231
+ }
2232
+
2233
+ /**
2234
+ * Open commit requests have no replacement in a quilt (12): each becomes a comment on its task,
2235
+ * or a chat line when it has none, "Please get this into history: <paths>" with its square.
2236
+ */
2237
+ quiltCommitRequests (squares) {
2238
+ const tasks = readTasks(this.tasks)
2239
+ for (const r of this.commitRequests.values()) {
2240
+ if (!r || r.state !== 'open') continue
2241
+ const sq = squares[r.branch || this.defaultKey]
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.`
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')
2244
+ else this.postChat({ by: 'Quilt', text, kind: 'note' })
2245
+ }
2246
+ }
2247
+
2248
+ /**
2249
+ * The owner's app asks to make the session a quilt (`op: 'quilt-migrate'`, `token`: their Seam
2250
+ * sign-in). Answered when it is done, on the branch channel like every branch request.
2251
+ */
2252
+ quiltMigrateRequest (ws, req) {
2253
+ const answer = (fields) => send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), reply: { id: req.id, op: req.op, ...fields } }))
2254
+ const api = process.env.QUILT_SOURCE_URL
2255
+ if (!api) return answer({ ok: false, error: 'Seam is not on for this relay, so a session cannot become a quilt here yet' })
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' })
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
+ }
2264
+ if (req.op === 'quilt-switch') {
2265
+ return this.quiltSwitch(this.access.get(ws), seam).then((r) => answer({ ok: true, ...r }), (err) => answer({ ok: false, error: err.message }))
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
+ }
2282
+ this.quiltMigrate(this.access.get(ws), seam).then(
2283
+ (r) => answer({ ok: true, quilt: r }),
2284
+ (err) => answer({ ok: false, error: err.message, ...(err.quilt_id ? { quilt_id: err.quilt_id } : {}) })
2285
+ )
2286
+ }
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
+
2539
+ /** The dry run of making this session a quilt, for someone with access `a` (session-quilt.js). */
2540
+ quiltPlan (a, tip = null) {
2541
+ const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
2542
+ if (no) throw new Error(no)
2543
+ this.assertMigrated()
2544
+ return planSessionQuilt(this.quiltSnapshot(), { tip: tip && typeof tip === 'object' ? tip : null })
2545
+ }
2546
+
2547
+ /**
2548
+ * The room as session-quilt.js's dry run reads it: each branch document's files (by hash),
2549
+ * history and merge records, the claims, tasks with pull requests, and commit requests.
2550
+ * Live square documents (quilt/<id>) are already Seam's and are left out.
2551
+ */
2552
+ quiltSnapshot () {
2553
+ const branches = []
2554
+ for (const [key, b] of Object.entries(this.meta.branches || {})) {
2555
+ if (isLiveKey(key)) continue
2556
+ const e = this.store.load(key)
2557
+ if (!e) continue
2558
+ const files = {}
2559
+ for (const [p, t] of e.files) { const s = t.toString(); files[p] = { hash: fileHash(s), bytes: Buffer.byteLength(s) } }
2560
+ for (const [p, x] of e.blobs) if (x && !files[p]) files[p] = { hash: String(x.hash || x.sha256 || ''), bytes: Number(x.size) || 0 }
2561
+ const merges = [...e.doc.getMap('merges').values()].map((m) => ({ id: m && m.id, state: m && m.state }))
2562
+ branches.push({ key, by: b.by || '', files, history: e.doc.getArray('history').toArray(), merges })
2563
+ }
2564
+ const claims = Object.values(this.meta.claims || {}).map((c) => ({ pattern: c.pattern, branch: this.branchOf(c), by: c.by || '', byId: c.byId || '', queue: c.queue || [] }))
2565
+ const tasks = [...this.tasks.entries()].map(([id, t]) => ({ id, pr: t && typeof t.get === 'function' ? t.get('pr') : t && t.pr })).filter((t) => t.pr)
2566
+ return {
2567
+ room: this.name,
2568
+ owner: this.meta.ownerSub || this.meta.ownerName || this.meta.owner || '',
2569
+ defaultKey: this.defaultKey,
2570
+ hasRemote: Object.keys(this.meta.upstreams || {}).length > 0,
2571
+ branches,
2572
+ claims,
2573
+ tasks,
2574
+ commitRequests: this.doc.getMap('commitRequests').size
2575
+ }
2576
+ }
2577
+
2578
+ /**
2579
+ * Makes this session a quilt (Seam's plans/quilt-source-control/16, "Without history"), behind
2580
+ * QUILT_SOURCE_URL: the owner's (as quiltPlan), with `seam` a Seam client acting as them.
2581
+ * The room is linked (`meta.quilt_id`, `meta.quilt`) only when every step worked, so a
2582
+ * failure leaves it a git-mode session as before. A room with a git remote goes with history
2583
+ * (the import and the cut-over, 14 and 16 step 3), not here.
2584
+ */
2585
+ async quiltMigrate (a, seam, { date = new Date() } = {}) {
2586
+ const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
2587
+ if (no) throw new Error(no.replace('see what making it a quilt would do', 'make it a quilt'))
2588
+ this.assertMigrated()
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}`)
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.
2594
+ const texts = (key) => {
2595
+ const e = this.store.load(key)
2596
+ const files = {}; const binaries = {}; const big = []; const modes = {}
2597
+ if (!e) return { files, binaries, modes, big, merges: [] }
2598
+ for (const [p, t] of e.files) files[p] = t.toString()
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')) : [] }
2602
+ }
2603
+ const def = texts(this.defaultKey)
2604
+ const leftOut = new Set(def.big)
2605
+ const branches = []
2606
+ for (const key of Object.keys(this.meta.branches || {})) {
2607
+ if (key === this.defaultKey || isLiveKey(key)) continue
2608
+ const t = texts(key)
2609
+ for (const p of t.big) leftOut.add(`${key}:${p}`)
2610
+ branches.push({ key, files: t.files, binaries: t.binaries, merges: t.merges })
2611
+ }
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 })
2613
+ this.meta.quilt_id = r.quilt_id
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)) || '' }
2615
+ // Open merges now live in Seam as conflict terms; the ones with no text to carry are named.
2616
+ if (r.terms.length || r.merges_left.length) this.meta.quilt.merges = { terms: r.terms, left: r.merges_left }
2617
+ this.meta.quilt.tasks = this.quiltTasks(this.meta.quilt.squares)
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
+ }
2623
+ this.saveMeta()
2624
+ this.broadcastBranches()
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()
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
2779
+ this.quiltMoving = false
2780
+ done()
2781
+ }
2782
+ }
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
+
2873
+ /**
2874
+ * The layout-3 switch (16, "How a session becomes a quilt"), after quiltMigrate linked the
2875
+ * quilt: each branch document is copied into its square's live document on the relay
2876
+ * (`quilt/<square id>`, an overlay over the square's head), the default branch's difference
2877
+ * from `main` into an adoption square ("Uncommitted work from <room>"), every copy is checked
2878
+ * against its branch document, and only then `meta.layout = 3` is written. A failed check
2879
+ * removes the copies and leaves the room at layout 2. The branch documents are not touched: at
2880
+ * layout 3 they are read-only, and quiltUnswitch goes back to them. Apps without `quilts` are
2881
+ * sent away (supported). The owner's, with `seam` acting as them, behind QUILT_SOURCE_URL.
2882
+ */
2883
+ async quiltSwitch (a, seam, { date = new Date() } = {}) {
2884
+ const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
2885
+ if (no) throw new Error(no.replace('see what making it a quilt would do', 'move its files into its squares'))
2886
+ if (this.meta.layout === LAYOUT_QUILT) throw new Error('this session\'s files are already in its squares')
2887
+ this.assertMigrated()
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)
3010
+ const made = []
3011
+ const claimed = []
3012
+ const unwatch = []
3013
+ let adoption = null
3014
+ const by = (a && (a.name || a.sub || a.key)) || ''
3015
+ try {
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) => {
3020
+ const e = this.store.load(key)
3021
+ return e ? branchFiles(e.doc, tree, { diff: isDiff(e), unheld: atTip.has(key) }) : {}
3022
+ }
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))
3045
+ const without = (o, skip) => Object.fromEntries(Object.entries(o).filter(([p]) => !skip.has(p)))
3046
+ const plan = []
3047
+ for (const [key, sq] of Object.entries(Q.squares || {})) {
3048
+ if (key === this.defaultKey || !this.meta.branches[key] || isLiveKey(key)) continue
3049
+ const s = await seam.square(q, sq)
3050
+ const head = s.head || s.base || null
3051
+ const skip = held(key)
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) })
3054
+ }
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)
3058
+ const adopt = overlayFor(def, main)
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) {
3064
+ const s = await seam.openSquare(q, { title: `Uncommitted work from ${this.name}`.slice(0, 200) })
3065
+ adoption = s.id
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
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()
3104
+ for (const p of plan) {
3105
+ const lk = liveKey(p.sq)
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`)
3107
+ made.push(lk)
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)
3114
+ }
3115
+ const check = (p) => {
3116
+ const bad = checkOverlay(this.store.load(liveKey(p.sq)).doc, p.files, p.tree)
3117
+ if (bad) throw new Error(`the check after copying ${p.key === this.defaultKey ? 'the default branch' : p.key} failed: ${bad}`)
3118
+ }
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
3198
+ this.meta.layout = LAYOUT_QUILT // last
3199
+ this.saveMeta()
3200
+ for (const ws of [...this.conns.keys(), ...this.pending.keys()]) {
3201
+ if (!ws.hosted && !this.supported(ws)) { ws.endReason = 'needs_update'; ws.close(CLOSE_NEEDS_UPDATE, this.needsUpdate()) }
3202
+ }
3203
+ this.broadcastBranches()
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 } : {}) }
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(() => {})
3208
+ for (const lk of made) this.removeBranch(lk)
3209
+ if (made.length) this.broadcastBranches()
3210
+ if (!into) this.endQuiltMove()
3211
+ throw new Error(`Migration undone: ${err.message}. The session stays as it was.`)
3212
+ } finally {
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 }
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) }
3485
+ }
3486
+
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 = [] } = {}) {
3496
+ const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
3497
+ if (no) throw new Error(no.replace('see what making it a quilt would do', 'move it back to branches'))
3498
+ if (this.meta.layout !== LAYOUT_QUILT) throw new Error('this session\'s files are not in squares')
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] }
3597
+ }
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
+
2098
3676
  /** Can this connection's app handle the session as it is now? */
2099
3677
  supported (ws) {
2100
- return !this.meta.largeFiles || (ws.features || []).includes('large-files')
3678
+ const f = ws.features || []
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
3681
+ return !this.meta.largeFiles || f.includes('large-files')
2101
3682
  }
2102
3683
 
2103
3684
  /**
@@ -2108,7 +3689,7 @@ class Room {
2108
3689
  if (this.meta.largeFiles) return
2109
3690
  this.meta.largeFiles = true
2110
3691
  for (const ws of [...this.conns.keys(), ...this.pending.keys()]) {
2111
- 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()) }
2112
3693
  }
2113
3694
  }
2114
3695
 
@@ -2722,6 +4303,18 @@ class Room {
2722
4303
  const at = (p) => this.claimKey(branch, p)
2723
4304
  const onBranch = branch === this.defaultKey ? {} : { branch }
2724
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
+ }
2725
4318
  if (req.op === 'claim') {
2726
4319
  if (!pattern) throw new Error('pattern required')
2727
4320
  if (pattern.length > MAX_PATTERN) throw new Error('pattern too long')
@@ -2890,6 +4483,8 @@ class Room {
2890
4483
  let joined = false
2891
4484
  let waiting = false
2892
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 || ''
2893
4488
  ws.on('message', (data) => {
2894
4489
  const buf = new Uint8Array(data)
2895
4490
  this.meter.in(buf.length)
@@ -2918,7 +4513,7 @@ class Room {
2918
4513
  // Its messages are the relay's own (relay-ask.js): nobody may write as it.
2919
4514
  if (isRelay(name)) return ws.close(CLOSE_NAME_TAKEN, `"${RELAY_BY}" is the relay's own name; pick another name`)
2920
4515
  // The session may have started storing files while this app signed in.
2921
- 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())
2922
4517
  // With sign-in on, names come from accounts and can't be taken, so they aren't bound to keys.
2923
4518
  if (!account) {
2924
4519
  // Re-check: someone else may have taken the name while we waited.
@@ -2950,8 +4545,9 @@ class Room {
2950
4545
  enter (ws, a) {
2951
4546
  this.setAccess(ws, a)
2952
4547
  this.noteTurns(ws, a)
2953
- // Presence: an account's visit starts once it's let in (never while it waits for the owner).
2954
- 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')) {
2955
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 || '' })
2956
4552
  // The accounts API learns who owns a session from this report, and only the owner may
2957
4553
  // give people access there: tell it now rather than within the minute.
@@ -2974,10 +4570,12 @@ class Room {
2974
4570
  join (ws, name) {
2975
4571
  this.conns.set(ws, new Set())
2976
4572
  this.names.set(ws, name)
2977
- send(ws, syncStep1Message(this.doc))
2978
- send(ws, jsonMessage(MSG_BRANCHES, { branches: this.branchList() }))
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))
4576
+ send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields() }))
2979
4577
  const states = [...this.awareness.getStates().keys()]
2980
- if (states.length) send(ws, awarenessMessage(this.awareness, states))
4578
+ if (states.length && !ws.roomless) send(ws, awarenessMessage(this.awareness, states))
2981
4579
  send(ws, jsonMessage(MSG_CLAIMS, { claims: this.claimList(ws.branch) }))
2982
4580
  this.touch()
2983
4581
  }
@@ -3040,6 +4638,9 @@ class Room {
3040
4638
  if (type === MSG_SYNC) {
3041
4639
  const docId = decoding.readVarString(dec)
3042
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
3043
4644
  if (docId !== ROOM_DOC) {
3044
4645
  // A connection syncs the room's document and the one branch it joined, nothing else.
3045
4646
  // An edit it sent for the branch it just left, still in flight when the join landed
@@ -3051,6 +4652,20 @@ class Room {
3051
4652
  // A branch over its size limit takes nothing new; the room and every other branch carry
3052
4653
  // on, and nobody is disconnected for it (its members hear it in the branch list).
3053
4654
  if (!this.unsavable && this.branchFull(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
+ }
3054
4669
  }
3055
4670
  if (docId === ROOM_DOC ? this.full : this.unsavable) {
3056
4671
  // Over quota: still answer "what do you have?" so people can read, but refuse new data.
@@ -3062,20 +4677,60 @@ class Room {
3062
4677
  }
3063
4678
  const enc = syncHeader(docId)
3064
4679
  const header = encoding.length(enc)
3065
- 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')
3066
4687
  if (encoding.length(enc) > header) send(ws, encoding.toUint8Array(enc))
3067
4688
  } else if (type === MSG_BRANCH) {
3068
4689
  let req = {}
3069
4690
  let reply
3070
4691
  try {
3071
4692
  req = JSON.parse(decoding.readVarString(dec))
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
+ }
3072
4727
  reply = { id: req.id, op: req.op, ...this.branchRequest(ws, req) }
3073
4728
  } catch (err) {
3074
4729
  reply = { id: req.id, op: req.op, ok: false, error: err.message }
3075
4730
  }
3076
4731
  const listChanged = reply.listChanged
3077
4732
  delete reply.listChanged
3078
- send(ws, jsonMessage(MSG_BRANCHES, { branches: this.branchList(), reply }))
4733
+ send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), reply }))
3079
4734
  if (reply.ok && reply.op === 'join') {
3080
4735
  // What the relay has of the branch, and its claims: the app answers with what it has.
3081
4736
  send(ws, syncStep1Message(this.store.get(ws.branch).doc, ws.branchAs))
@@ -3127,7 +4782,9 @@ class Room {
3127
4782
  const id = req.id
3128
4783
  const a = this.access.get(ws)
3129
4784
  if (!a || !ws.branch) throw new Error('not in this session')
3130
- this.gitTokenFor(ws.branch, { access: req.access === 'write' ? 'write' : 'read', who: { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', id: a.id }, repo: typeof req.repo === 'string' ? req.repo : null })
4785
+ // In a session that is a quilt, an agent's git gets no GitHub token: it uses Seam (session-seam.js).
4786
+ const onSeam = gitTokenRefusal(quiltOf(this.meta), a.kind === 'agent' ? 'agent' : 'human')
4787
+ ;(onSeam ? Promise.reject(new Error(onSeam)) : this.gitTokenFor(ws.branch, { access: req.access === 'write' ? 'write' : 'read', who: { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', id: a.id }, repo: typeof req.repo === 'string' ? req.repo : null }))
3131
4788
  .then((git) => ({ id, ok: true, git }), (err) => ({ id, ok: false, error: err.message }))
3132
4789
  .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
3133
4790
  return
@@ -4020,6 +5677,23 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4020
5677
  json(200, { ok: true, id: msg.id })
4021
5678
  })
4022
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
+ }
4023
5697
  // Someone connected GitHub (the API's callback): every session they are in checks its repository again now.
4024
5698
  if (url.pathname === '/github/connected' && req.method === 'POST') {
4025
5699
  const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
@@ -4036,6 +5710,50 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4036
5710
  json(200, { ok: true, rooms: n })
4037
5711
  })
4038
5712
  }
5713
+ // Making a session a quilt from the accounts API (POST /v1/sessions/:room/migrate, which checked the
5714
+ // owner's sign-in): { room, account, dry_run } plans it; { room, account, token } makes it, as the
5715
+ // owner, with their Seam sign-in. The room's own owner check still applies (Room.quiltPlan).
5716
+ if (url.pathname === '/quilt/migrate' && req.method === 'POST') {
5717
+ if (!fromApi(req)) return json(401, { error: 'only the accounts API may say this' })
5718
+ return readJson(req, 16 * 1024, (err, b) => {
5719
+ if (err || !b) return json(400, { error: 'send JSON' })
5720
+ const name = String(b.room || '')
5721
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return json(400, { error: 'give the room' })
5722
+ if (!/^[a-z]+:[A-Za-z0-9_-]{1,64}$/.test(String(b.account || ''))) return json(400, { error: 'give the account' })
5723
+ const api = process.env.QUILT_SOURCE_URL
5724
+ if (!api) return json(409, { error: 'Seam is not on for this relay, so a session cannot become a quilt here yet' })
5725
+ const onDisk = dataDir && fs.existsSync(path.join(dataDir, `${name}.json`))
5726
+ if ((!rooms.has(name) && !onDisk) || roomEnded(name)) return json(404, { error: 'no such session' })
5727
+ const room = getRoom(name)
5728
+ if (!room) return json(409, { error: 'this session is not on this relay machine' })
5729
+ const access = { state: 'approved', role: 'editor', scopes: [], owner: !room.controlled || room.meta.ownerSub === b.account, sub: b.account }
5730
+ if (room.controlled && !access.owner) return json(403, { error: 'only the session\'s owner can make it a quilt' })
5731
+ if (b.dry_run === true) {
5732
+ try {
5733
+ const plan = room.quiltPlan(access, b.tip && typeof b.tip === 'object' ? b.tip : null)
5734
+ return json(200, { ok: true, plan, text: describeQuiltPlan(plan) })
5735
+ } catch (e) { return json(409, { error: e.message }) }
5736
+ }
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' })
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
+ }
5742
+ if (b.step === 'switch') {
5743
+ return room.quiltSwitch(access, seam).then((r) => json(200, { ok: true, ...r }), (e) => json(409, { error: e.message }))
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
+ }
5751
+ room.quiltMigrate(access, seam).then(
5752
+ (quilt) => json(200, { ok: true, quilt }),
5753
+ (e) => json(409, { error: e.message, ...(e.quilt_id ? { quilt_id: e.quilt_id } : {}) })
5754
+ )
5755
+ })
5756
+ }
4039
5757
  // Moving a room between machines (the accounts API runs it, src/api/relay-cluster.js): the source
4040
5758
  // lets it go, the target copies it from the source, the source forgets its copy (or takes it back).
4041
5759
  const cm = url.pathname.match(/^\/cluster\/rooms\/([A-Za-z0-9_-]{1,64})\/(release|export|fetch|forget|restore)$/)
@@ -4358,12 +6076,12 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4358
6076
  // People and agents come from different id spaces, so the kind is part of who they are.
4359
6077
  const account = pass ? `${pass.kind}:${pass.sub}` : ''
4360
6078
  // Checked before the room is touched: a room's creator is recorded by this key (see authorize).
4361
- 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 }))
4362
6080
  const key = parsePublicKey(publicKey)
4363
6081
  if (!person || person.length > MAX_NAME || !key) return reject(socket, 400, 'Bad name or identity key')
4364
6082
  // Every session keeps its files in branch documents: an app that can't sync them is told to update.
4365
6083
  const features = String(url.searchParams.get('features') || '').split(',')
4366
- if (!features.includes('branches')) return reject(socket, 400, NEEDS_UPDATE)
6084
+ if (!features.includes('branches')) return reject(socket, 400, withUpdateSteps(NEEDS_UPDATE, { ascii: true }))
4367
6085
  if (roomEnded(name)) return reject(socket, 410, ENDED_MESSAGE)
4368
6086
  const ip = clientIp(req)
4369
6087
  const starter = pass ? account : ip
@@ -4379,9 +6097,10 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4379
6097
  return reject(socket, auth === 'need-key' ? 403 : 401, auth === 'need-key' ? 'Relay key required to create rooms' : 'Wrong room secret')
4380
6098
  }
4381
6099
  // Checked after the secret, so only members learn what the session needs.
4382
- if (room.meta.largeFiles && !features.includes('large-files')) {
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) {
4383
6102
  if (!room.conns.size && room.onEmpty) room.onEmpty() // don't keep it in memory for nobody
4384
- return reject(socket, 400, NEEDS_UPDATE)
6103
+ return reject(socket, 400, withUpdateSteps(noQuilt ? NEEDS_UPDATE_QUILT : NEEDS_UPDATE, { ascii: true }))
4385
6104
  }
4386
6105
  if (!pass && !room.keyMatches(person, publicKey)) return reject(socket, 403, nameTaken(person))
4387
6106
  wss.handleUpgrade(req, socket, head, (ws) => {
@@ -4479,7 +6198,8 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4479
6198
  upstreamTicker?.unref()
4480
6199
  // Live-sync leases renewed, and let go of for sessions that ended or sat idle for a day (relay-lease.js).
4481
6200
  const leaseTicker = cfg.gitSync ? setInterval(() => {
4482
- 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) => {
4483
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() } }
4484
6204
  }).catch((err) => log(`lease tick: ${err.message}`))
4485
6205
  }, cfg.leaseHeartbeatMs || LEASE_HEARTBEAT_MS) : null
@@ -4671,11 +6391,25 @@ function receiveBlob (req, file, limit, done) {
4671
6391
  const TOO_BIG = 'Session over the size limit'
4672
6392
  const UNREADABLE = "This session's data can't be read on the relay right now"
4673
6393
  const ENDED_MESSAGE = 'The owner ended this session'
4674
- 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
4675
6406
  const SIGN_IN = 'Update Quilt and sign in to continue'
4676
6407
  const NOT_LET_IN = "The session owner hasn't let you in yet."
4677
6408
  const PASS_EXPIRED = 'Your sign-in expired. Reconnecting.'
4678
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
+
4679
6413
  function reject (socket, code, message) {
4680
6414
  socket.write(`HTTP/1.1 ${code} ${message}\r\nConnection: close\r\n\r\n`)
4681
6415
  socket.destroy()