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