heyquilt 0.3.122 → 0.3.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/RELEASES.md +273 -17
  2. package/bin/quilt.js +55 -9
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -2
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +16 -9
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/session-migrate.js +7 -4
  15. package/src/api/routes/sessions.js +73 -1
  16. package/src/api/server.js +13 -7
  17. package/src/api/supabase-store.js +43 -4
  18. package/src/autowork.js +182 -16
  19. package/src/connection.js +36 -9
  20. package/src/control.js +129 -15
  21. package/src/force-date.js +27 -0
  22. package/src/fsutil.js +13 -3
  23. package/src/gitstate.js +38 -2
  24. package/src/history.js +13 -3
  25. package/src/mcp.js +87 -12
  26. package/src/merge3.js +11 -3
  27. package/src/now-summary.js +113 -0
  28. package/src/pathrules.js +12 -0
  29. package/src/protocol.js +10 -2
  30. package/src/quilt-default.js +84 -0
  31. package/src/quilt-history.js +106 -0
  32. package/src/quilt-remote.js +78 -0
  33. package/src/relay-commit.js +5 -0
  34. package/src/relay-lease.js +6 -2
  35. package/src/relay-mcp.js +151 -17
  36. package/src/relay-outbound.js +1 -1
  37. package/src/release-words.js +2 -2
  38. package/src/runner.js +20 -7
  39. package/src/seam-access.js +97 -0
  40. package/src/seam-claims.js +53 -0
  41. package/src/seam-url.js +15 -0
  42. package/src/server.js +1517 -86
  43. package/src/session-follow.js +461 -27
  44. package/src/session-layout3.js +410 -4
  45. package/src/session-migrate.js +15 -11
  46. package/src/session-quilt.js +1 -1
  47. package/src/session-seam.js +233 -20
  48. package/src/session.js +848 -67
  49. package/src/source-live/bring-in.js +283 -0
  50. package/src/source-live/live-block.js +504 -33
  51. package/src/source-live/live-square.js +16 -5
  52. package/src/status.js +5 -0
  53. package/src/ui/agent-guide.js +1 -1
  54. package/src/ui/app.css +85 -20
  55. package/src/ui/app.js +4 -1
  56. package/src/ui/chat.js +9 -3
  57. package/src/ui/cloud-ai-model.js +13 -0
  58. package/src/ui/cloud-ai.js +10 -2
  59. package/src/ui/common.js +6 -2
  60. package/src/ui/fileview.js +20 -7
  61. package/src/ui/force-words.js +19 -0
  62. package/src/ui/git-rule.js +9 -0
  63. package/src/ui/home.js +0 -147
  64. package/src/ui/loom-model.js +15 -1
  65. package/src/ui/quiltview.js +213 -0
  66. package/src/ui/seam-model.js +251 -0
  67. package/src/ui/session.js +413 -31
  68. package/src/ui/voice-call.js +83 -14
  69. package/src/ui/voice-model.js +39 -0
  70. package/src/ui/voice.js +167 -17
  71. package/src/ui-server.js +80 -28
  72. package/src/update-check.js +25 -1
  73. package/src/voice.js +13 -6
  74. package/src/git.js +0 -147
  75. package/src/quilts/AGENTS.md +0 -1
  76. package/src/quilts/CLAUDE.md +0 -1
  77. package/src/quilts/accounts.js +0 -136
  78. package/src/quilts/affected.js +0 -132
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/checks.js +0 -308
  87. package/src/quilts/claims.js +0 -144
  88. package/src/quilts/cli.js +0 -536
  89. package/src/quilts/client.js +0 -137
  90. package/src/quilts/conflict-format.js +0 -190
  91. package/src/quilts/describe.js +0 -296
  92. package/src/quilts/dogfood.js +0 -82
  93. package/src/quilts/errors.js +0 -8
  94. package/src/quilts/events.js +0 -171
  95. package/src/quilts/export-git.js +0 -130
  96. package/src/quilts/export-remote.js +0 -156
  97. package/src/quilts/export.js +0 -81
  98. package/src/quilts/fastmerge.js +0 -41
  99. package/src/quilts/hash.js +0 -44
  100. package/src/quilts/history.js +0 -266
  101. package/src/quilts/ids.js +0 -12
  102. package/src/quilts/import-follow.js +0 -236
  103. package/src/quilts/import-git.js +0 -294
  104. package/src/quilts/import.js +0 -196
  105. package/src/quilts/index.js +0 -94
  106. package/src/quilts/keys.js +0 -186
  107. package/src/quilts/limits.js +0 -67
  108. package/src/quilts/lines.js +0 -49
  109. package/src/quilts/live.js +0 -50
  110. package/src/quilts/merge-smart.js +0 -114
  111. package/src/quilts/merge.js +0 -501
  112. package/src/quilts/oauth.js +0 -202
  113. package/src/quilts/objects.js +0 -144
  114. package/src/quilts/pack.js +0 -118
  115. package/src/quilts/records.js +0 -211
  116. package/src/quilts/resolutions.js +0 -69
  117. package/src/quilts/reviews.js +0 -208
  118. package/src/quilts/routes.js +0 -186
  119. package/src/quilts/runner.js +0 -95
  120. package/src/quilts/server.js +0 -144
  121. package/src/quilts/service.js +0 -963
  122. package/src/quilts/setup.js +0 -41
  123. package/src/quilts/signing.js +0 -149
  124. package/src/quilts/squares.js +0 -239
  125. package/src/quilts/store-memory.js +0 -844
  126. package/src/quilts/store-rpc.js +0 -90
  127. package/src/quilts/store-supabase.js +0 -13
  128. package/src/quilts/tags.js +0 -64
  129. package/src/quilts/textdiff.js +0 -96
  130. package/src/quilts/tokens.js +0 -66
  131. package/src/quilts/tools.js +0 -403
  132. package/src/quilts/tree.js +0 -351
  133. package/src/quilts/webhook.js +0 -116
  134. package/src/quilts/window.js +0 -121
  135. package/src/quilts/xdiff.js +0 -435
  136. package/src/ui/branches.js +0 -111
@@ -1,18 +1,42 @@
1
- // Quilt's side of Seam (plans/quilt-source-control/16, "How a session becomes a quilt"): once a
1
+ // Quilt's side of Seam (Seam's plans/quilt-source-control/16, "How a session becomes a quilt"): once a
2
2
  // session is switched to layout 3 (session-layout3.js), each branch's live work is a square's live
3
3
  // document on the relay (`quilt/<square>`), and a folder follows it through a live block
4
4
  // (source-live/live-block.js): its files mirror the square, every edit goes into the live document.
5
5
  //
6
- // SquareFollower is that link for one Session: `follow({ dir, client })` opens the block for the
7
- // session's branch, and the relay's `quilt` message (switched back, square moved) or the session
8
- // stopping closes it. Nothing here runs unless QUILT_SOURCE_URL is set and the relay says the
9
- // session is a quilt at layout 3, so GitHub-backed sessions never reach it.
6
+ // SquareFollower is that link for one Session. The session's own folder follows its branch's square
7
+ // in place, by itself, as soon as the relay says the session is a layout-3 quilt (`onQuilt`): the
8
+ // folder already equals the square's live document (the switch copied the branch document into it
9
+ // and checked), so nothing is checked out; the session's branch-document sync and git-mode upstream
10
+ // work stop for the folder (Session.enterSquare) and the block takes over. When the relay says the
11
+ // session went back (layout 2), or the branch's square changed, the block closes and the old sync
12
+ // resumes (Session.leaveSquare). `follow({ dir, client })` also opens a block in a SEPARATE folder
13
+ // (`quilt session follow <folder>`). Nothing here runs with Seam off for the app (seam-url.js) or for
14
+ // a session the relay does not say is a quilt at layout 3, so GitHub-backed sessions never reach it.
15
+ //
16
+ // In a quilt, agents see no git and no GitHub at all: it is all Seam. So while the folder follows its
17
+ // square, its .git (a folder, or a worktree's pointer file) is set aside in .quilt/git-before-seam with
18
+ // one rename, and git there answers "not a git repository"; going back to layout 2 puts it back before
19
+ // git-mode sync resumes, and a restart while following keeps it aside. It is never deleted.
20
+ import fs from 'node:fs'
10
21
  import path from 'node:path'
22
+ import * as Y from 'yjs'
11
23
  import { FEATURES } from './protocol.js'
12
24
  import { openLiveBlock } from './source-live/live-block.js'
25
+ import { appSeamUrl } from './seam-url.js'
26
+ import { lastWriters } from './source-live/live-square.js'
27
+ import { broughtOf } from './source-live/bring-in.js'
28
+ import { catchUpWords } from './ui/seam-model.js'
29
+ import { QUILT_MOVE_TIMEOUT_MS } from './connection.js'
30
+ import { squareShort } from '@heyquilt/seam/lib/quilts/errors.js'
13
31
 
14
32
  const LAYOUT_QUILT = 3
15
33
  const PREFIX = 'quilt/'
34
+ // A folder whose block could not open (the relay or Seam did not answer) tries again this often, its sync still held.
35
+ const RETRY_MS = 30 * 1000
36
+ // The files of the square's uncommitted work that status lists (the count is all of them).
37
+ const WORK_LISTED = 50
38
+ // How often a folder asks whether main moved past its square's base (catchUp), besides when the relay says something of the quilt.
39
+ export const CATCH_UP_MS = Number(process.env.QUILT_CATCH_UP_MS) || 60 * 1000
16
40
 
17
41
  /** The square the folder on `branch` follows: `{ square, key }`, or null while the session is not a layout-3 quilt. */
18
42
  export function squareFor (quilt, branch) {
@@ -22,19 +46,243 @@ export function squareFor (quilt, branch) {
22
46
  return { square: key.slice(PREFIX.length), key }
23
47
  }
24
48
 
25
- const withQuilts = (f) => (String(f || FEATURES).split(',').includes('quilts') ? String(f || FEATURES) : `${f || FEATURES},quilts`)
49
+ export const GIT_ASIDE = path.join('.quilt', 'git-before-seam')
50
+
51
+ /** The folder's .git set aside while it follows its square: { branch, at, kind } (from its note), or null. */
52
+ export function gitAsideOf (root) {
53
+ const aside = path.join(root, GIT_ASIDE)
54
+ try { fs.lstatSync(aside) } catch { return null }
55
+ try { return JSON.parse(fs.readFileSync(`${aside}.json`, 'utf8')) } catch { return { branch: null } }
56
+ }
57
+
58
+ // Written into a worktree's admin folder in its repository (git worktree lock) while its .git is aside.
59
+ const LOCK_REASON = 'Quilt: this worktree follows a Seam square, with its .git set aside in .quilt/git-before-seam; Quilt unlocks it when git comes back.\n'
60
+
61
+ /** Where a worktree's .git file points (its repository's .git/worktrees/<name>), or null. */
62
+ function worktreeAdmin (gitFile) {
63
+ try {
64
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitFile, 'utf8'))
65
+ return m ? path.resolve(path.dirname(gitFile), m[1].trim()) : null
66
+ } catch { return null }
67
+ }
68
+
69
+ /** The other worktrees of the repository whose .git folder is `gitFolder` that are still there: their folders. */
70
+ export function otherWorktrees (gitFolder) {
71
+ let names
72
+ try { names = fs.readdirSync(path.join(gitFolder, 'worktrees')) } catch { return [] }
73
+ const out = []
74
+ for (const n of names.sort()) {
75
+ try {
76
+ const dot = fs.readFileSync(path.join(gitFolder, 'worktrees', n, 'gitdir'), 'utf8').trim()
77
+ if (dot && fs.existsSync(dot)) out.push(path.dirname(dot))
78
+ } catch {}
79
+ }
80
+ return out
81
+ }
82
+
83
+ const real = (p) => { try { return fs.realpathSync(p) } catch { return path.resolve(p) } }
84
+
85
+ /**
86
+ * The folder's own linked worktrees (`git worktree add`, in the folder or anywhere else): each one's
87
+ * .git pointer file names `<.git>/worktrees/<name>`, so moving the .git would leave them answering
88
+ * "not a git repository". [{ file, was, name }] for each pointer that names this .git.
89
+ */
90
+ function linkedWorktrees (gitDir) {
91
+ let names = []
92
+ try { names = fs.readdirSync(path.join(gitDir, 'worktrees')) } catch { return [] }
93
+ const out = []
94
+ for (const name of names) {
95
+ const admin = path.join(gitDir, 'worktrees', name)
96
+ try {
97
+ let file = fs.readFileSync(path.join(admin, 'gitdir'), 'utf8').trim()
98
+ if (!path.isAbsolute(file)) file = path.resolve(admin, file)
99
+ // A worktree that follows a square too has its pointer set aside in its own .quilt.
100
+ for (const at of pointerPlaces(file)) {
101
+ let was
102
+ try { was = fs.readFileSync(at, 'utf8') } catch { continue }
103
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(was)
104
+ if (m && real(path.resolve(path.dirname(file), m[1])) === real(admin)) out.push({ file, was, name })
105
+ break
106
+ }
107
+ } catch {}
108
+ }
109
+ return out
110
+ }
111
+
112
+ /** Where a worktree's .git pointer file is: at its .git, or set aside (it follows a square too). */
113
+ const pointerPlaces = (file) => [file, path.join(path.dirname(file), GIT_ASIDE)]
114
+
115
+ /**
116
+ * Rewrites a worktree's .git pointer file in place, wherever it is now (pointerPlaces), when `was(text)`
117
+ * says it is the one expected: through an open handle, never by making the file anew, so a worktree
118
+ * that sets its own .git aside meanwhile (both follow squares, at once) finds it in its .quilt and
119
+ * not a new .git in its folder. Whether it wrote.
120
+ */
121
+ function rewritePointer (file, text, was) {
122
+ for (const at of pointerPlaces(file)) {
123
+ let fd
124
+ try { if (!fs.statSync(at).isFile()) continue; fd = fs.openSync(at, 'r+') } catch { continue }
125
+ try {
126
+ if (!was(fs.readFileSync(fd, 'utf8'))) return false
127
+ fs.ftruncateSync(fd, 0)
128
+ fs.writeSync(fd, text, 0)
129
+ return true
130
+ } catch { return false } finally { fs.closeSync(fd) }
131
+ }
132
+ return false
133
+ }
134
+
135
+ /**
136
+ * Sets the folder's .git aside (one rename into .quilt): { moved, already?, both?, locked?, worktrees? }.
137
+ * A .git kept aside from before (a restart while following) stays where it is; a new .git made
138
+ * meanwhile is left alone (both: true), never moved over the one aside. A worktree's .git file
139
+ * points into its repository: that worktree is locked there meanwhile (locked: true), so git in the
140
+ * repository never prunes it as gone. A repository's other worktrees share its .git folder: their
141
+ * .git files are pointed at it where it is now (and back as they were with it, putGitBack), so
142
+ * they keep working; `worktrees` names their folders.
143
+ */
144
+ export function setGitAside (root, { branch = null } = {}) {
145
+ const git = path.join(root, '.git')
146
+ const aside = path.join(root, GIT_ASIDE)
147
+ let st = null
148
+ try { st = fs.lstatSync(git) } catch {}
149
+ if (gitAsideOf(root)) return { moved: false, already: true, ...(st ? { both: true } : {}) }
150
+ if (!st) return { moved: false }
151
+ const kind = st.isDirectory() ? 'folder' : 'file'
152
+ const admin = kind === 'file' ? worktreeAdmin(git) : null
153
+ let locked = null
154
+ if (admin && fs.existsSync(admin) && !fs.existsSync(path.join(admin, 'locked'))) {
155
+ fs.writeFileSync(path.join(admin, 'locked'), LOCK_REASON)
156
+ locked = admin
157
+ }
158
+ const worktrees = kind === 'folder' ? otherWorktrees(git) : []
159
+ const linked = kind === 'folder' ? linkedWorktrees(git) : []
160
+ fs.mkdirSync(path.dirname(aside), { recursive: true })
161
+ fs.writeFileSync(`${aside}.json`, JSON.stringify({ branch, at: new Date().toISOString(), kind, ...(locked ? { locked } : {}), ...(linked.length ? { worktrees: linked.map(({ file, was }) => ({ file, was })) } : {}) }))
162
+ try { fs.renameSync(git, aside) } catch (err) {
163
+ if (locked) fs.rmSync(path.join(locked, 'locked'), { force: true })
164
+ fs.rmSync(`${aside}.json`, { force: true })
165
+ throw err
166
+ }
167
+ for (const w of linked) rewritePointer(w.file, `gitdir: ${path.join(real(aside), 'worktrees', w.name)}\n`, (now) => now === w.was)
168
+ return { moved: true, ...(locked ? { locked: true } : {}), ...(worktrees.length ? { worktrees } : {}) }
169
+ }
170
+
171
+ /** Puts the .git set aside back (one rename): true when it did; false with nothing aside, or with a new .git in the way (both kept). */
172
+ export function putGitBack (root) {
173
+ const git = path.join(root, '.git')
174
+ const aside = path.join(root, GIT_ASIDE)
175
+ const note = gitAsideOf(root)
176
+ if (!note) return false
177
+ try { fs.lstatSync(git); return false } catch {}
178
+ fs.renameSync(aside, git)
179
+ // Its linked worktrees' pointers, as they were (one changed since is left as it is).
180
+ for (const w of note.worktrees || []) rewritePointer(w.file, w.was, (now) => now.includes(GIT_ASIDE))
181
+ fs.rmSync(`${aside}.json`, { force: true })
182
+ // The lock this set, and only that one (someone may have locked the worktree since, for their own reason):
183
+ // where it was set, or where its repository's .git is now (set aside meanwhile, its pointer says).
184
+ if (note.locked) {
185
+ for (const admin of new Set([note.locked, worktreeAdmin(git)].filter(Boolean))) {
186
+ const lock = path.join(admin, 'locked')
187
+ try { if (fs.readFileSync(lock, 'utf8') === LOCK_REASON) fs.rmSync(lock, { force: true }) } catch {}
188
+ }
189
+ }
190
+ return true
191
+ }
192
+
193
+ // The branch document's maps keyed by path (Session.bindBranchDoc): what a file's change touches.
194
+ const PATH_MAPS = ['files', 'blobs', 'gone', 'modes']
195
+
196
+ /**
197
+ * The paths of branch document `doc` with changes the relay does not hold (its state vector `sv`,
198
+ * and `deleted`, an update of its deletions, both encoded): at layout 3, edits the read-only
199
+ * document refused (they reached it after the switch) or never got (the app was offline). A change
200
+ * that only deleted text counts too: Yjs keeps deletions apart from the state vector, so the relay's
201
+ * own are compared.
202
+ */
203
+ export function unsentPaths (doc, sv, deleted) {
204
+ const out = new Set()
205
+ const held = Y.decodeStateVector(sv)
206
+ const gone = Y.decodeUpdate(deleted).ds.clients
207
+ const past = (item) => item.id.clock + item.length > (held.get(item.id.client) || 0)
208
+ const goneThere = (item) => (gone.get(item.id.client) || []).some((d) => d.clock <= item.id.clock && item.id.clock + item.length <= d.clock + d.len)
209
+ const touched = (item) => past(item) || (item.deleted && !goneThere(item))
210
+ for (const name of PATH_MAPS) {
211
+ for (const [p, item] of doc.getMap(name)._map) {
212
+ if (touched(item)) { out.add(p); continue }
213
+ const type = item.content && item.content.type
214
+ if (!(type instanceof Y.Text)) continue
215
+ for (let it = type._start; it; it = it.right) if (touched(it)) { out.add(p); break }
216
+ }
217
+ }
218
+ return out
219
+ }
220
+
221
+ const withQuilts =(f) => (String(f || FEATURES).split(',').includes('quilts') ? String(f || FEATURES) : `${f || FEATURES},quilts`)
222
+
223
+ /** The one line the app and quilt_status show for a folder that follows its square. */
224
+ export function squareLine ({ square, quilt, problem = '', opening = false }) {
225
+ const short = squareShort(square || '')
226
+ const name = (quilt && (quilt.slug || quilt.id)) || 'the quilt'
227
+ if (problem) return `This folder's work belongs in square ${short} of ${name}, but it can't follow it yet (${problem}); Quilt tries again by itself, and nothing here is sent anywhere meanwhile.`
228
+ if (opening) return `This folder is starting to follow square ${short} of ${name}.`
229
+ return `This folder's work is live in square ${short} of ${name}: every edit reaches everyone on it. Commit it with quilt commit "<message>".`
230
+ }
231
+
232
+ // How long a read of the quilt through the relay may take: the relay reads Seam with the room's one
233
+ // read token and retries a busy Seam (429, 503) itself, so a folder waits for it rather than giving up
234
+ // after a branch request's 10 s (the second rehearsal: an app restarted with two others failed its
235
+ // first try that way, and followed 30 s later).
236
+ export const QUILT_READ_TIMEOUT_MS = 60 * 1000
237
+
238
+ /**
239
+ * Reads of the room's quilt through the relay (server.js quiltBlockRead), with the room's read
240
+ * token: the calls a block reads with, so a member needs no Seam sign-in to follow its square.
241
+ */
242
+ export function relayReader (conn) {
243
+ const read = async (fields) => {
244
+ if (!conn || !conn.connected) throw new Error('not connected to the relay')
245
+ try {
246
+ return await conn.branchRequest({ op: 'quilt-block', ...fields }, { timeoutMs: QUILT_READ_TIMEOUT_MS })
247
+ } catch (err) {
248
+ throw new Error(`reading the square's ${fields.call} through the relay: ${err.message}`)
249
+ }
250
+ }
251
+ return {
252
+ square: async (q, s) => (await read({ call: 'square', square: s })).square,
253
+ tree: async (q, rev, paths) => (await read({ call: 'tree', rev, ...(paths ? { paths } : {}) })).tree,
254
+ blobs: async (q, ids) => {
255
+ const out = {}
256
+ for (let i = 0; i < ids.length; i += 200) Object.assign(out, (await read({ call: 'blobs', ids: ids.slice(i, i + 200) })).blobs)
257
+ return { blobs: out }
258
+ },
259
+ diff: async (q, o = {}) => (await read({ call: 'diff', from: o.from, to: o.to, ...(o.paths ? { paths: o.paths } : {}) })).diff,
260
+ cat: async (q, rev, file, o = {}) => (await read({ call: 'cat', rev, file, format: o.format })).cat
261
+ }
262
+ }
26
263
 
27
264
  export class SquareFollower {
28
265
  /** @param {object} session a Session (server, room, secret, name, identity, passes, tool, kind, features, quilt, branchKey(), log) */
29
- constructor (session, { open = openLiveBlock } = {}) {
266
+ constructor (session, { open = openLiveBlock, retryMs = RETRY_MS, catchUpMs = CATCH_UP_MS } = {}) {
30
267
  this.session = session
31
268
  this.open = open
32
- this.following = null // { square, dir, block }
269
+ this.retryMs = retryMs
270
+ this.catchUpMs = catchUpMs
271
+ this.following = null // { square, dir, block }: a separate folder
272
+ this.here = null // { square, quilt, block, opening, problem }: the session's own folder, in place
273
+ this.turn = Promise.resolve() // one change of square at a time
274
+ }
275
+
276
+ relayOpts () {
277
+ const s = this.session
278
+ // A person's session has no identity of its own (it signs in with passes): its connection loads this computer's.
279
+ const identity = s.identity || (s.conn && s.conn.identity) || null
280
+ return { server: s.server, room: s.room, secret: s.secret, name: s.name, identity, passes: s.passes, tool: s.tool, kind: s.kind, features: withQuilts(s.features) }
33
281
  }
34
282
 
35
283
  /** Opens the block at `dir` (its own folder, signed in to Seam with `client`) on the session's branch's square. */
36
284
  async follow ({ dir, client } = {}) {
37
- if (!process.env.QUILT_SOURCE_URL) throw new Error('Seam is not on for this app (QUILT_SOURCE_URL is not set).')
285
+ if (!appSeamUrl()) throw new Error('Seam is turned off for this app (QUILT_SOURCE_URL=off).')
38
286
  if (!dir) throw new Error('Give the folder the square\'s files go to.')
39
287
  const s = this.session
40
288
  // Its own folder: inside the session's folder, the block's files would sync a second time.
@@ -47,35 +295,221 @@ export class SquareFollower {
47
295
  const at = squareFor(s.quilt, branch)
48
296
  if (!at) throw new Error(`The session is a quilt, but ${branch} has no live square yet (the owner switches it with quilt session migrate --switch).`)
49
297
  if (this.following && this.following.square === at.square && this.following.dir === dir) return { square: at.square, dir }
50
- await this.close()
51
- const block = await this.open({
52
- dir,
53
- client,
54
- quilt: s.quilt.id,
55
- square: at.square,
56
- api: process.env.QUILT_SOURCE_URL,
57
- relay: { server: s.server, room: s.room, secret: s.secret, name: s.name, identity: s.identity, passes: s.passes, tool: s.tool, kind: s.kind, features: withQuilts(s.features) }
58
- })
298
+ await this.closeSeparate()
299
+ const block = await this.open({ dir, client, quilt: s.quilt.id, square: at.square, api: appSeamUrl(), relay: this.relayOpts() })
59
300
  this.following = { square: at.square, dir, block }
60
301
  if (s.log) s.log(`🧵 ${dir} follows the square ${at.square} of ${s.quilt.slug || s.quilt.id}: edits there are live for everyone on it.`)
61
302
  return { square: at.square, dir }
62
303
  }
63
304
 
64
- /** The relay's `quilt` message: let go of a square the branch no longer has (switched back, moved). */
65
- async onQuilt (quilt) {
66
- if (!this.following) return
67
- const at = squareFor(quilt, this.session.branchKey())
68
- if (at && at.square === this.following.square) return
69
- const was = this.following.square
70
- await this.close()
71
- if (this.session.log) this.session.log(`🧵 stopped following the square ${was}: ${at ? `the branch is on ${at.square} now` : 'the session is back on its branch documents'}.`)
305
+ /** The relay's `quilt` message: the folder follows its branch's square in place, or lets go of one the branch no longer has. */
306
+ onQuilt (quilt) {
307
+ const s = this.session
308
+ // Held before the first await: nothing of the folder reaches the read-only branch document while the block opens.
309
+ const at = this.inPlaceOn() ? squareFor(quilt, s.squareBranchKey ? s.squareBranchKey() : s.branchKey()) : null
310
+ if (at && s.enterSquare && !(this.here && this.here.square === at.square)) s.enterSquare(at, quilt)
311
+ const next = this.turn.then(() => this.settle(quilt)).catch((err) => { if (s.log) s.log(`🧵 ${err.message}`) })
312
+ this.turn = next
313
+ // A merge onto main moves a branch on (quiltNext), and the relay says so: the other squares may be behind now.
314
+ next.then(() => this.catchUpSoon())
315
+ return next
72
316
  }
73
317
 
74
- async close () {
318
+ /** Seam on for the app, a folder of the session's own, and a session that can hold its sync (a Session). */
319
+ inPlaceOn () { return !!(appSeamUrl() && this.session.root && this.session.enterSquare) }
320
+
321
+ async settle (quilt) {
322
+ const s = this.session
323
+ const at = this.inPlaceOn() ? squareFor(quilt, s.squareBranchKey ? s.squareBranchKey() : s.branchKey()) : null
324
+ // The separate folder lets go of a square the branch no longer has (switched back, moved).
325
+ const sep = this.following && squareFor(quilt, s.branchKey())
326
+ if (this.following && !(sep && sep.square === this.following.square)) {
327
+ const was = this.following.square
328
+ await this.closeSeparate()
329
+ if (s.log) s.log(`🧵 stopped following the square ${was}: ${sep ? `the branch is on ${sep.square} now` : 'the session is back on its branch documents'}.`)
330
+ }
331
+ if (this.here && (!at || at.square !== this.here.square)) {
332
+ const was = this.here.square
333
+ // Moved on (a merge): what the folder has not sent yet reaches the old square first, and the relay takes it on to the new one.
334
+ if (at && this.here.block && this.here.block.flush) await this.here.block.flush().catch(() => {})
335
+ const writers = this.writersHere()
336
+ await this.closeHere()
337
+ if (!at) {
338
+ if (s.leaveSquare) await s.leaveSquare({ writers })
339
+ if (s.log) s.log(`🧵 this folder stopped following square ${squareShort(was)}: the session is back on its branches, and the folder syncs with its branch as before.`)
340
+ } else if (s.log) s.log(`🧵 this folder left square ${squareShort(was)}: its branch is on ${squareShort(at.square)} now.`)
341
+ }
342
+ if (!at || (this.here && this.here.square === at.square)) return
343
+ await this.openHere(at, quilt)
344
+ }
345
+
346
+ /** The session's own folder becomes the square's block (live-block.js, in place). */
347
+ async openHere (at, quilt, { fresh = false } = {}) {
348
+ const s = this.session
349
+ const here = { square: at.square, quilt, block: null, problem: '', opening: true }
350
+ this.here = here
351
+ if (s.scheduleStatusWrite) s.scheduleStatusWrite()
352
+ try {
353
+ // Edits the read-only branch document refused (they reached it after the switch) are the folder's too.
354
+ const unsent = !fresh && s.unsentPaths ? await s.unsentPaths().catch(() => null) : null
355
+ const block = await this.open({
356
+ dir: s.root,
357
+ // A square opened for this folder's branch (Session.openSquareHere: the branch had none) takes the folder as it is: its files are the work.
358
+ inPlace: fresh ? { edited: () => false, fresh: true } : s.folderWins === at.square ? { edited: () => true } : { edited: (p) => s.editedSinceSync(p) || !!(unsent && unsent.has(p)) || !!(s.boxedHere && s.boxedHere(p)), boxed: (p) => !!(s.boxedHere && s.boxedHere(p)), heldBack: (p) => !!(s.heldBackHere && s.heldBackHere(p)), synced: (p) => !!(s.syncedHere && s.syncedHere(p)), base: s.syncedText ? (p) => s.syncedText(p) : null, own: () => (s.branchOwnPaths ? s.branchOwnPaths() : null), atTip: ((quilt && quilt.atTip) || []).includes(s.squareBranchKey ? s.squareBranchKey() : s.branchKey()) },
359
+ skip: (p) => !s.syncable(p),
360
+ skipDir: s.ignoredDir ? (p) => s.ignoredDir(p) : null,
361
+ record: path.join(s.stateDir || path.join(s.root, '.quilt'), 'block.json'),
362
+ reader: s.squareReader ? s.squareReader() : relayReader(s.conn),
363
+ client: s.seamWriteClient ? await s.seamWriteClient() : null,
364
+ quilt: quilt.id,
365
+ square: at.square,
366
+ api: appSeamUrl(),
367
+ relay: this.relayOpts(),
368
+ log: (m) => { if (s.log) s.log(`🧵 ${m}`) },
369
+ started: (b) => { here.starting = b },
370
+ // Who changed what goes on as on the branch: this folder's edits on record as its member's.
371
+ onEdits: s.squareEdits ? (list, doc, held) => { if (this.here === here) s.squareEdits(list, doc, held) } : null
372
+ })
373
+ if (this.here !== here) { await block.close().catch(() => {}); return }
374
+ here.block = block
375
+ here.opening = false
376
+ if (s.followSquareDoc) s.followSquareDoc(block.doc)
377
+ // What the square holds not committed yet (status `work`) changes with every edit on it, anyone's: the app hears.
378
+ if (block.doc && block.doc.on && s.scheduleStatusWrite) block.doc.on('update', () => { if (this.here === here) s.scheduleStatusWrite() })
379
+ if (s.log) s.log(`🧵 ${squareLine({ square: at.square, quilt })}`)
380
+ // Another member's app brought main in, or a commit moved the head: how far behind, and what conflicts, asked again.
381
+ if (block.doc && block.doc.getMap) block.doc.getMap('live').observe(() => { if (this.here === here) this.catchUpSoon() })
382
+ here.catchUpTimer = setInterval(() => this.catchUp().catch(() => {}), this.catchUpMs)
383
+ here.catchUpTimer.unref?.()
384
+ this.catchUp().catch(() => {})
385
+ } catch (err) {
386
+ if (this.here !== here) return
387
+ here.opening = false
388
+ here.problem = String(err.message || err).slice(0, 300)
389
+ if (s.log) s.log(`🧵 ${squareLine({ square: at.square, quilt, problem: here.problem })}`)
390
+ clearTimeout(this.retryTimer)
391
+ this.retryTimer = setTimeout(() => {
392
+ if (this.here !== here || s.stopped) return
393
+ this.here = null
394
+ this.onQuilt(s.quilt)
395
+ }, this.retryMs)
396
+ if (this.retryTimer.unref) this.retryTimer.unref()
397
+ }
398
+ if (s.scheduleStatusWrite) s.scheduleStatusWrite()
399
+ }
400
+
401
+ /** git moved the folder to another branch: it follows that branch's square, as the square has it (git's checkout is not an edit). */
402
+ async branchMoved (quilt) {
403
+ const s = this.session
404
+ const at = squareFor(quilt, s.squareBranchKey())
405
+ if (at && this.here && this.here.square === at.square) return
406
+ const was = this.here && this.here.square
407
+ const writers = this.writersHere()
408
+ await this.closeHere()
409
+ if (!at) {
410
+ if (s.leaveSquare) await s.leaveSquare({ writers })
411
+ return
412
+ }
413
+ if (s.log && was) s.log(`🧵 this folder left square ${squareShort(was)} for ${s.squareBranchKey()}'s, ${squareShort(at.square)}.`)
414
+ s.enterSquare(at, quilt)
415
+ await this.openHere(at, quilt, { fresh: true })
416
+ }
417
+
418
+ /** catchUp in a moment: the relay says something of the quilt in bursts, and one ask answers them all. */
419
+ catchUpSoon () {
420
+ if (!this.here || !this.here.block || this.catchUpTimer) return
421
+ this.catchUpTimer = setTimeout(() => { this.catchUpTimer = null; this.catchUp().catch(() => {}) }, 1000)
422
+ this.catchUpTimer.unref?.()
423
+ }
424
+
425
+ /**
426
+ * Bringing main in (server.js quiltCatchUp, source-live/bring-in.js): asks the relay, as this member,
427
+ * whether main moved past the square's base; when it did and it is this member's turn (its holder
428
+ * first, else a writer), the relay brings main's new patches into the square, and this folder, like
429
+ * every folder on it, gets the new files. The answer says how far behind the square is, for status.
430
+ */
431
+ async catchUp () {
432
+ const s = this.session
433
+ const h = this.here
434
+ if (!h || !h.block || h.catching || s.stopped || !s.conn || !s.conn.connected || !s.seamMember || !appSeamUrl()) return null
435
+ h.catching = true
436
+ try {
437
+ const { token } = await s.seamMember().client(h.quilt.id, [h.square])
438
+ const branch = s.squareBranchKey ? s.squareBranchKey() : s.branchKey()
439
+ const r = await s.conn.branchRequest({ op: 'quilt-catch-up', token, branch }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS })
440
+ if (this.here !== h) return null
441
+ h.behind = r.behind || 0
442
+ h.conflicted = r.conflicted || 0
443
+ // Main waits for a folder on the square too old to follow its base moving (server.js outdatedOn): whose.
444
+ h.outdated = Array.isArray(r.outdated) ? r.outdated.map(String).slice(0, 10) : []
445
+ h.catchUpProblem = ''
446
+ if (r.brought && s.log) s.log(`🧵 ${catchUpWords({ brought: { ...r.brought, at: Date.now() } }).join(' ')}`)
447
+ if (s.scheduleStatusWrite) s.scheduleStatusWrite()
448
+ return r
449
+ } catch (err) {
450
+ if (this.here === h && h.catchUpProblem !== err.message) {
451
+ h.catchUpProblem = err.message
452
+ if (s.log) s.log(`🧵 couldn't check whether main moved past square ${squareShort(h.square)} (${err.message}); Quilt asks again in a minute.`)
453
+ }
454
+ return null
455
+ } finally { h.catching = false }
456
+ }
457
+
458
+ /** Where the session's own folder stands, for status: { square, quilt, line, problem?, opening? }, or null. */
459
+ status () {
460
+ const h = this.here
461
+ if (!h) return null
462
+ const quilt = { id: h.quilt.id, slug: h.quilt.slug || '' }
463
+ const problem = h.problem || (h.block && (h.block.problem || h.block.outOfStep)) || ''
464
+ // `work`: the square's live work not committed yet (everyone's on it, as the relay holds it), for the app's count and list.
465
+ let work = null
466
+ try { if (h.block && h.block.status) { const all = h.block.status(); work = { count: all.length, files: all.slice(0, WORK_LISTED) } } } catch {}
467
+ // Main brought in (by this app or another member's): the live document keeps the last time, for everyone.
468
+ const b = h.block ? broughtOf(h.block.doc) : null
469
+ const brought = b ? { at: b.at, by: b.by || '', patches: b.patches || 0, combined: b.combined || [], conflicts: b.conflicts || [], ...(Array.isArray(b.stale) && b.stale.length ? { stale: b.stale } : {}) } : null
470
+ const catching = { ...(h.behind ? { behind: h.behind } : {}), ...(h.behind && h.outdated && h.outdated.length ? { outdated: h.outdated } : {}), ...(h.conflicted !== undefined ? { conflicted: h.conflicted } : {}), ...(brought ? { brought } : {}) }
471
+ const words = h.block ? catchUpWords(catching).join(' ') : ''
472
+ return { square: h.square, short: squareShort(h.square).slice(3), quilt, ...(h.opening ? { opening: true } : {}), ...(problem ? { problem } : {}), ...(work ? { work } : {}), ...catching, line: squareLine({ square: h.square, quilt, problem: h.problem, opening: h.opening }) + (h.block && h.block.problem ? ` ${h.block.problem}.` : '') + (h.block && h.block.outOfStep ? ` ${h.block.outOfStep}.` : '') + (words ? ` ${words}` : '') }
473
+ }
474
+
475
+ /** Who last wrote each path of the square's live work (live-square.js lastWriters), or null without an open block. */
476
+ writersHere () {
477
+ const b = this.here && this.here.block
478
+ try { return b && b.doc ? lastWriters(b.doc) : null } catch { return null }
479
+ }
480
+
481
+ /** Commits the square's live work from the session's own folder (live-block.js commit). */
482
+ async commit ({ message = '' } = {}) {
483
+ const h = this.here
484
+ if (!h) throw new Error('This folder does not follow a square: the session is not a quilt at layout 3 (or Seam is off for this app).')
485
+ if (!h.block) throw new Error(h.problem ? `This folder can't follow its square yet: ${h.problem}` : 'This folder is still starting to follow its square; try again in a moment.')
486
+ if (!message || !String(message).trim()) throw new Error('Say what the commit is (a message).')
487
+ return h.block.commit({ message: String(message).slice(0, 2000) })
488
+ }
489
+
490
+ async closeHere () {
491
+ const h = this.here
492
+ this.here = null
493
+ clearTimeout(this.retryTimer)
494
+ clearTimeout(this.catchUpTimer)
495
+ this.catchUpTimer = null
496
+ if (h) clearInterval(h.catchUpTimer)
497
+ if (this.session.unfollowSquareDoc) this.session.unfollowSquareDoc()
498
+ if (h && h.block && h.block.close) await h.block.close().catch(() => {})
499
+ // Still opening (waiting for the relay, say): stopped where it is, or its connection outlives the folder's square.
500
+ else if (h && h.starting && h.starting.abandon) h.starting.abandon()
501
+ }
502
+
503
+ async closeSeparate () {
75
504
  const f = this.following
76
505
  this.following = null
77
506
  if (f && f.block && f.block.close) await f.block.close().catch(() => {})
78
507
  }
79
508
 
509
+ async close () {
510
+ await this.closeSeparate()
511
+ await this.closeHere()
512
+ }
513
+
80
514
  stop () { return this.close() }
81
515
  }