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