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.
- package/RELEASES.md +273 -17
- package/bin/quilt.js +55 -9
- package/desktop/main.js +16 -2
- package/package.json +3 -2
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +16 -9
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/session-migrate.js +7 -4
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +13 -7
- package/src/api/supabase-store.js +43 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +36 -9
- package/src/control.js +129 -15
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +13 -3
- package/src/mcp.js +87 -12
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +151 -17
- package/src/relay-outbound.js +1 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1517 -86
- package/src/session-follow.js +461 -27
- package/src/session-layout3.js +410 -4
- package/src/session-migrate.js +15 -11
- package/src/session-quilt.js +1 -1
- package/src/session-seam.js +233 -20
- package/src/session.js +848 -67
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +504 -33
- package/src/source-live/live-square.js +16 -5
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/accounts.js +0 -136
- package/src/quilts/affected.js +0 -132
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/checks.js +0 -308
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -536
- package/src/quilts/client.js +0 -137
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -296
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -171
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export-remote.js +0 -156
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-follow.js +0 -236
- package/src/quilts/import-git.js +0 -294
- package/src/quilts/import.js +0 -196
- package/src/quilts/index.js +0 -94
- package/src/quilts/keys.js +0 -186
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -501
- package/src/quilts/oauth.js +0 -202
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -208
- package/src/quilts/routes.js +0 -186
- package/src/quilts/runner.js +0 -95
- package/src/quilts/server.js +0 -144
- package/src/quilts/service.js +0 -963
- package/src/quilts/setup.js +0 -41
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -844
- package/src/quilts/store-rpc.js +0 -90
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -403
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -121
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
package/src/session-follow.js
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
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.
|
|
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 (!
|
|
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.
|
|
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:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
}
|