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/session.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
// back. Any tool that edits files on disk (Claude Code, Cursor, Codex, vim...)
|
|
3
3
|
// participates automatically; concurrent edits are merged character by
|
|
4
4
|
// character by the CRDT.
|
|
5
|
+
import { describeQuiltPlan } from './session-quilt.js'
|
|
6
|
+
import { SquareFollower, relayReader, gitAsideOf, setGitAside, putGitBack, unsentPaths, GIT_ASIDE, squareFor } from './session-follow.js'
|
|
7
|
+
import { appSeamUrl } from './seam-url.js'
|
|
8
|
+
import { pushRefusal } from './session-seam.js'
|
|
9
|
+
import { quiltClient } from '@heyquilt/seam/lib/quilts/client.js'
|
|
5
10
|
import fs from 'node:fs'
|
|
6
11
|
import path from 'node:path'
|
|
7
12
|
import crypto from 'node:crypto'
|
|
@@ -10,7 +15,7 @@ import { execFile, execFileSync } from 'node:child_process'
|
|
|
10
15
|
import { EventEmitter } from 'node:events'
|
|
11
16
|
import * as Y from 'yjs'
|
|
12
17
|
import { watch } from 'chokidar'
|
|
13
|
-
import { Connection, PUSH_REQUEST_TIMEOUT_MS } from './connection.js'
|
|
18
|
+
import { Connection, PUSH_REQUEST_TIMEOUT_MS, QUILT_MOVE_TIMEOUT_MS } from './connection.js'
|
|
14
19
|
import { loadIdentity } from './identity.js'
|
|
15
20
|
import { writePrivateJson } from './private-file.js'
|
|
16
21
|
import { MAX_SHARED_FILE_BYTES, FEATURES } from './protocol.js'
|
|
@@ -25,7 +30,12 @@ import { migrateDir } from './legacy.js'
|
|
|
25
30
|
import { withComments, addComment as putComment } from './task-comments.js'
|
|
26
31
|
import { readTasks, addTask as putTask, updateTask as patchTask, deleteTask as dropTask, planAutoTask, nextTask, pickupMode, columnName } from './tasks.js'
|
|
27
32
|
import { getSettings } from './settings.js'
|
|
28
|
-
import { HistoryLog, queryHistory, parseSince, currentTask, blameChange, lineDiff } from './history.js'
|
|
33
|
+
import { HistoryLog, queryHistory, parseSince, currentTask, blameChange, lineDiff, legacyTail } from './history.js'
|
|
34
|
+
import { linkedHistory, mergeLinked } from './quilt-history.js'
|
|
35
|
+
import { readAccount } from './account.js'
|
|
36
|
+
import { seamAccess, sessionCredential, isAgentKey } from './seam-access.js'
|
|
37
|
+
import { startAsQuilt } from './quilt-default.js'
|
|
38
|
+
import { releaseInSeam } from './seam-claims.js'
|
|
29
39
|
import { historyMarks, awayChanges, mergeCatchUp, emptyCatchUp } from './catchup.js'
|
|
30
40
|
import { Inbox, mentionsMe, describeEvent } from './inbox.js'
|
|
31
41
|
import { projectRules, renderRules, gatherPlan, planKey } from './project-rules.js'
|
|
@@ -46,7 +56,7 @@ import { merge3, mergeAuto, hasMarkers } from './merge3.js'
|
|
|
46
56
|
import { sameEditClient, asClient } from './same-edit.js'
|
|
47
57
|
import { openMerge, updateMerge, readMerges, pruneMerges, cleanName } from './merges.js'
|
|
48
58
|
import { ensureQuiltIgnored } from './gitignore.js'
|
|
49
|
-
import { urlOrigin, gitDir, headKey, headRef, gitRuns, askTwice, lastCallTimedOut, busy as gitBusy, leftoverLock, STALE_LOCK_MS, indexStamp, classify, filesAt, changesBetween, commitsBetween, treeState, branchTip, watchGit, unmergedPaths, stashStamp, upstreamAdds, pullState, SETTLE_MS, BURST_PATHS, upstreamOf, fetchUpstream, isAncestor, stagedAgainst, fastForward, resetIndex, blobAt, committedAs, hasFilesUnder, repoBranches, remoteHost, backWithGit, installGitHelpers, setPersonGit, hasCommit, pushLocal, bundleOf, adoptPublished, fetchSignedIn, movedRemoteUrl, commitTimes, pushedBase, changedSince } from './gitstate.js'
|
|
59
|
+
import { urlOrigin, gitDir, headKey, headRef, gitRuns, askTwice, lastCallTimedOut, busy as gitBusy, leftoverLock, STALE_LOCK_MS, indexStamp, classify, filesAt, changesBetween, commitsBetween, treeState, branchTip, watchGit, unmergedPaths, stashStamp, upstreamAdds, pullState, SETTLE_MS, BURST_PATHS, upstreamOf, fetchUpstream, isAncestor, stagedAgainst, fastForward, resetIndex, blobAt, committedAs, hasFilesUnder, repoBranches, remoteHost, backWithGit, installGitHelpers, setPersonGit, hasCommit, pushLocal, bundleOf, adoptPublished, fetchSignedIn, movedRemoteUrl, commitTimes, pushedBase, changedSince, ownSinceFork, linksAt } from './gitstate.js'
|
|
50
60
|
import { planCatchUp, catchUpAdvice, publishedOf, notePublished } from './upstream.js'
|
|
51
61
|
import { agentPrefix, noreply, slug, gitForEveryoneWords } from './github-commit.js'
|
|
52
62
|
import { parseRepo, upstreamRef } from './relay-upstream.js'
|
|
@@ -57,13 +67,12 @@ import { cleanTyping, TYPING_MS, AGENT_TYPING_MS } from './ui/chat.js'
|
|
|
57
67
|
import { DEFAULT_KEY } from './branchdocs.js'
|
|
58
68
|
import { boxEntry } from './boxes.js'
|
|
59
69
|
import { cleanVoice, newCallId, voiceView, inVoice, transcribeSetting, setTranscribe, addLine, readLines, MAX_IN_CALL, CALL_FULL } from './voice.js'
|
|
60
|
-
import {
|
|
70
|
+
import { toolInUse } from './agents/common.js'
|
|
71
|
+
import { squareShort } from '@heyquilt/seam/lib/quilts/errors.js'
|
|
61
72
|
|
|
62
73
|
export { applyTextDiff }
|
|
63
74
|
|
|
64
75
|
const LOCAL = Symbol('local')
|
|
65
|
-
// The tools a person can be shown as using (a guessed one gives way to the AI that connects).
|
|
66
|
-
const KNOWN_TOOLS = new Set(['Claude Code', 'Cursor', 'Codex', 'xAI', 'Windsurf', 'Zed'])
|
|
67
76
|
const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' // git's empty tree, the base of a branch pushed from nothing
|
|
68
77
|
|
|
69
78
|
/**
|
|
@@ -80,6 +89,8 @@ function defaultCli () {
|
|
|
80
89
|
const CLASH = Symbol('clash') // clash tasks this Quilt writes (quiet for its own inbox until settleClash)
|
|
81
90
|
const CLASH_REASSIGN_MS = 5 * 60 * 1000 // a clash task's assignee gone this long: the next member takes it
|
|
82
91
|
const CLASH_WAIT_MS = 30 * 1000 // no clash task from the first in line this long: the next one writes it
|
|
92
|
+
const FILE_LOG_MS = 30 * 1000 // a file's History in the app reads the quilt's log at most this often (fileHistory)
|
|
93
|
+
const HISTORY_SEAM_OFF_MS = 60 * 1000 // quilt_history reads through the relay this long after Seam would not let this member in
|
|
83
94
|
const CLASH_IDLE_MS = 15 * 60 * 1000 // a clash assignee with no progress this long is handed on
|
|
84
95
|
const CLASH_SETTLE_MS = 2000 // a clash task this member wrote is told to its AI after this long, if it survived a race
|
|
85
96
|
const CLASH_KEEP_MS = 3 * 24 * 60 * 60 * 1000 // a finished clash record is kept this long
|
|
@@ -149,9 +160,36 @@ const SETTLE_BOXES_MS = 1500 // after an edit to one of our boxed files: does gi
|
|
|
149
160
|
const RELAY_ASK_EVERY_MS = 5 * 1000 // a folder waiting for the relay asks it again this often (server.js SYNC_EVERY_GIT_MS)
|
|
150
161
|
const INDEX_WATCH_MS = 8000 // after a bring-in, how long the index is checked against what another git may write back
|
|
151
162
|
|
|
163
|
+
// The owner's app renews the room's read token this long before it ends, looks this often, and tries again after this (renewReadToken).
|
|
164
|
+
const READ_RENEW_BEFORE_MS = 30 * 86400e3
|
|
165
|
+
const READ_RENEW_CHECK_MS = 6 * 3600e3
|
|
166
|
+
const READ_RENEW_RETRY_MS = 3600e3
|
|
167
|
+
|
|
168
|
+
/** What a move says of large files in the session's storage that git does not have (server.js quiltSwitchNow): the folders put them in. */
|
|
169
|
+
const largeLine = (list) => `Large files in the session's storage that git does not have are not in the squares yet (the relay can't read them); each folder that has them puts them in its square when it follows it, with a Seam sign-in on that computer: ${list.slice(0, 8).map((x) => `${x.path} (${x.branch}${x.size ? `, ${Math.max(1, Math.round(x.size / 1048576))} MB` : ''})`).join(', ')}${list.length > 8 ? `, and ${list.length - 8} more` : ''}.`
|
|
170
|
+
|
|
171
|
+
/** What every member is told while the session moves (server.js withMigrationLease `moving`): what to expect, in plain words. */
|
|
172
|
+
export const movingLine = (m) => m && m.kind === 'back'
|
|
173
|
+
? 'The session\'s code is moving back from Seam to its git branches. Keep Quilt open: it can take a few minutes, and your edits are kept; your folder takes git back when it is done.'
|
|
174
|
+
: 'The session\'s code is moving into Seam, where it will be committed and merged; the session stays in Quilt. Keep Quilt open: it can take a few minutes on a large session, and your edits are kept; your folder then follows its square by itself.'
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
/** The move's word on people Seam would not make writers of a square: their AIs can't commit there until they are. */
|
|
178
|
+
function notSharedLines (list) {
|
|
179
|
+
if (!(list || []).length) return []
|
|
180
|
+
const by = new Map()
|
|
181
|
+
for (const x of list) { const k = `${x.member || 'everyone'} (${x.why})`; by.set(k, [...(by.get(k) || []), squareShort(x.square)]) }
|
|
182
|
+
const each = [...by].slice(0, 6).map(([who, sqs]) => `${who} in ${sqs.join(', ')}`)
|
|
183
|
+
return [`Not every person could be made a writer of every square, so their AIs can't commit there yet: ${each.join('; ')}${by.size > 6 ? '; …' : ''}. Add them to the quilt (seam quilts add <member> --level write) and share the square (seam_share).`]
|
|
184
|
+
}
|
|
185
|
+
/** What a move says when it made the room's read token again (server.js freshReadToken). */
|
|
186
|
+
const readTokenLine = (why) => `The session's read token for its quilt was ${why === 'missing' ? 'missing' : why === 'expiring' ? 'about to end' : 'no longer taken by Seam'}, so a new one was made: every folder reads its square with it.`
|
|
187
|
+
|
|
152
188
|
export class Session extends EventEmitter {
|
|
153
|
-
constructor ({ dir, server, room, secret, key = '', viewSecret = '', name, tool = 'unknown',
|
|
189
|
+
constructor ({ dir, server, room, secret, key = '', viewSecret = '', name, tool = 'unknown', color = null, prefer = 'remote', kind = 'human', shareAgent = true, summarize = null, identity = null, passes = null, startName = '', startAgentCommits = '', newSession = false, quiltDefault = null, workspace = '', autoClaimQuietMs = AUTO_CLAIM_QUIET_MS, handoffGraceMs = HANDOFF_GRACE_MS, aiTasks = null, pushLocalCommits = null, webhookTransport = null, pullWaitMs = PULL_WAIT_MS, bringInUpstream = true, clashReassignMs = CLASH_REASSIGN_MS, clashWaitMs = CLASH_WAIT_MS, clashSettleMs = CLASH_SETTLE_MS, clashIdleMs = CLASH_IDLE_MS, catcherWaitMs = CATCHER_WAIT_MS, gitBacking = true, gitRemote = null, followsRelay = true, turns = false, features = null, cli = defaultCli(), apiAuth = null }) {
|
|
154
190
|
super()
|
|
191
|
+
// The Quilt sign-in this session runs as, for the accounts API and Seam (seam-access.js): its person's, or its agent's key.
|
|
192
|
+
this.apiAuth = apiAuth
|
|
155
193
|
// An agent that works in turns (agent-join.js keptInProject): the relay shows it away between them.
|
|
156
194
|
this.turns = turns
|
|
157
195
|
this.pullWaitMs = pullWaitMs
|
|
@@ -179,8 +217,6 @@ export class Session extends EventEmitter {
|
|
|
179
217
|
// Signs in to a relay that requires it (see pass-source.js), with passes for this room.
|
|
180
218
|
this.passes = passes && passes.forRoom ? passes.forRoom(room) : passes
|
|
181
219
|
this.tool = tool
|
|
182
|
-
// Nobody picked `tool`, Quilt guessed it (tool-guess.js): the first AI that connects corrects it.
|
|
183
|
-
this.toolGuessed = toolGuessed
|
|
184
220
|
this.color = color
|
|
185
221
|
this.prefer = prefer
|
|
186
222
|
this.stateDir = migrateDir(this.root)
|
|
@@ -247,6 +283,9 @@ export class Session extends EventEmitter {
|
|
|
247
283
|
this.relayProblem = null // why the relay can't be reached, when we know (setRelayProblem)
|
|
248
284
|
this.agentPrompts = new Map() // conv -> latest prompt line, so an edit can be titled after the question that started it
|
|
249
285
|
this.merging = new Set() // paths held out of normal sync until their offline merge has run
|
|
286
|
+
// At layout 3 the folder follows its branch's square in place (session-follow.js): { square, since }, and
|
|
287
|
+
// while it does, its branch-document sync and git-mode upstream work are held (enterSquare).
|
|
288
|
+
this.inSquare = null
|
|
250
289
|
this.catchUp = null // "while you were away" (catchup.js), until the person dismisses it
|
|
251
290
|
this.awayBackups = null // while joining: copies of ours kept in .quilt/conflicts, for the catch-up
|
|
252
291
|
this.work = null // { state: 'working'|'done', note, ts }: what an agent says it's doing
|
|
@@ -324,6 +363,10 @@ export class Session extends EventEmitter {
|
|
|
324
363
|
this.startAgentCommits = startAgentCommits // the person's default for what agents may commit, set on a new room the same way
|
|
325
364
|
this.workspace = workspace // the workspace this session is in (the app knows); told to the relay as owner, so webhook events can say so
|
|
326
365
|
this.startNameSent = false
|
|
366
|
+
// A session this app just started: with QUILT_SOURCE_DEFAULT on the API it becomes a quilt from the start (quilt-default.js).
|
|
367
|
+
this.newSession = !!newSession
|
|
368
|
+
this.quiltDefault = quiltDefault // tests: replaces startAsQuilt's options
|
|
369
|
+
this.startQuiltTried = false
|
|
327
370
|
this.logs = [] // the last 200 log lines (for status and tests)
|
|
328
371
|
// Git awareness (gitstate.js): a git operation in this folder is recognised, not broadcast as edits.
|
|
329
372
|
this.git = null // { key, branch, sha } this folder was on when the session started (null: not a repo)
|
|
@@ -462,8 +505,10 @@ export class Session extends EventEmitter {
|
|
|
462
505
|
this.gone = doc.getMap('gone')
|
|
463
506
|
this.fileBases = doc.getMap('fileBases')
|
|
464
507
|
this.merges = doc.getMap('merges')
|
|
465
|
-
|
|
466
|
-
|
|
508
|
+
// While the folder follows a square, who changed what is the square's (followSquareDoc).
|
|
509
|
+
const record = this.squareDoc || doc
|
|
510
|
+
this.tallies = record.getMap('changes')
|
|
511
|
+
this.history = new HistoryLog(record, record.getArray('history'), { origin: LOCAL })
|
|
467
512
|
}
|
|
468
513
|
|
|
469
514
|
/**
|
|
@@ -630,6 +675,8 @@ export class Session extends EventEmitter {
|
|
|
630
675
|
// Back in a folder we synced before: the line is an offline edit, merged once synced. A first
|
|
631
676
|
// join adds it after taking the room's files (reconcileFirstJoin), which may replace .gitignore.
|
|
632
677
|
if (hadState) this.ignoreQuiltState()
|
|
678
|
+
// Restarted while the folder follows its square: its .git is set aside (session-follow.js) and stays so; its branch is the note's.
|
|
679
|
+
this.gitAside = gitDir(this.root) ? null : gitAsideOf(this.root)
|
|
633
680
|
this.git = await headKey(this.root)
|
|
634
681
|
this.gitSeen = this.git
|
|
635
682
|
// A repo whose git can't be run (not on the PATH of an app started from the Dock, say) or
|
|
@@ -641,7 +688,7 @@ export class Session extends EventEmitter {
|
|
|
641
688
|
const resumed = hadState && this.resumeHold()
|
|
642
689
|
// The branch document this folder syncs: the branch it syncs in git (still the one it was paused off, until
|
|
643
690
|
// the folder follows git), ∅ (the room's default) without git.
|
|
644
|
-
this.branch = this.git ? this.git.key : (gitDir(this.root) && headRef(this.root)) || DEFAULT_KEY
|
|
691
|
+
this.branch = this.git ? this.git.key : (gitDir(this.root) && headRef(this.root)) || (this.gitAside && this.gitAside.branch) || DEFAULT_KEY
|
|
645
692
|
// The form saved is the saved branch's; another branch's is the relay's to say when it is joined.
|
|
646
693
|
if (this.savedBranch !== this.branch) { this.branchForm = null; this.diffBase = null }
|
|
647
694
|
const diffJoin = await this.diffJoinFields()
|
|
@@ -681,6 +728,29 @@ export class Session extends EventEmitter {
|
|
|
681
728
|
this.conn.on('access', (a) => this.setAccess(a))
|
|
682
729
|
this.conn.on('members', (m) => this.setMembers(m))
|
|
683
730
|
this.conn.on('branches', (list) => this.setBranches(list))
|
|
731
|
+
// Seam (Seam's plans/quilt-source-control/16): the relay says when the session is a quilt, and which.
|
|
732
|
+
this.conn.on('quilt', (q) => {
|
|
733
|
+
this.quilt = q
|
|
734
|
+
this.emit('quilt', q)
|
|
735
|
+
// At layout 3 this folder follows its branch's square by itself (session-follow.js).
|
|
736
|
+
if (!this.squareFollower) this.squareFollower = new SquareFollower(this)
|
|
737
|
+
this.squareFollower.onQuilt(q).catch(() => {})
|
|
738
|
+
this.renewReadToken().catch(() => {})
|
|
739
|
+
})
|
|
740
|
+
// A write of this folder's reached a read-only branch document at layout 3: it was not kept (server.js refusedReadOnly).
|
|
741
|
+
this.conn.on('read-only', (r) => this.noteReadOnly(r))
|
|
742
|
+
this.conn.on('quilt-shadow', (q) => { this.quiltShadow = q; this.emit('quilt-shadow', q) })
|
|
743
|
+
this.conn.on('importing', (x) => { this.quiltImporting = x; this.scheduleStatusWrite(); this.emit('importing', x) })
|
|
744
|
+
// The forced date (16 flag 9, force-date.js): live sync stopped here, or the owner's open makes the session a quilt.
|
|
745
|
+
this.conn.on('force', (f) => this.onForce(f))
|
|
746
|
+
// A move of the session (into Seam, or back) runs on the relay: said in the log, the app and quilt_status while it lasts.
|
|
747
|
+
this.conn.on('moving', (m) => {
|
|
748
|
+
const was = this.moving
|
|
749
|
+
this.moving = m
|
|
750
|
+
if (m && !was) this.log(`🧵 ${movingLine(m)}`)
|
|
751
|
+
else if (!m && was) this.log(`🧵 the move of the session's code ${was.kind === 'back' ? 'back to its git branches' : 'into Seam'} is over.`)
|
|
752
|
+
this.scheduleStatusWrite()
|
|
753
|
+
})
|
|
684
754
|
// GitHub moved this folder's branch: fetch and bring it in now, not at the next minute's look.
|
|
685
755
|
this.conn.on('pushed', (p) => {
|
|
686
756
|
if (!this.git || !this.git.branch || p.branch !== this.branchKey() || this.stopped) return
|
|
@@ -712,7 +782,9 @@ export class Session extends EventEmitter {
|
|
|
712
782
|
const gitNow = this.diffForm && !resumed && this.git && this.git.sha ? await changedSince(this.root, this.git.sha).catch(() => null) : null
|
|
713
783
|
const offline = resumed ? { entries: [], take: [], downloads: [] } : this.captureOffline(gitNow)
|
|
714
784
|
this.goLive()
|
|
715
|
-
|
|
785
|
+
// A folder that follows its square (its .git set aside) is compared with the branch document it last synced, which the
|
|
786
|
+
// square's work has moved past: its block's first look says what went in, so nothing is said here.
|
|
787
|
+
if (offline.entries.length && !gitAsideOf(this.root)) this.log(`${offline.entries.length} file(s) changed while you were away; merging once the relay has synced…`)
|
|
716
788
|
const synced = this.conn.waitForSync()
|
|
717
789
|
// An agent's folder synced before folders were git-backed: once the relay has synced, with its
|
|
718
790
|
// branch's repository known, it is made a checkout, and git is watched from then on.
|
|
@@ -777,6 +849,8 @@ export class Session extends EventEmitter {
|
|
|
777
849
|
await this.maybeBackWithGit()
|
|
778
850
|
}
|
|
779
851
|
await this.startWatcher()
|
|
852
|
+
// git was set aside while following a square, and the session is not one at layout 3 now (or Seam is off here): it comes back.
|
|
853
|
+
if (this.gitAside) this.conn.waitForSync().then(() => { if (!this.inSquare && !this.stopped) return this.gitBack() }).catch(() => {})
|
|
780
854
|
return this
|
|
781
855
|
}
|
|
782
856
|
|
|
@@ -790,7 +864,7 @@ export class Session extends EventEmitter {
|
|
|
790
864
|
*/
|
|
791
865
|
async maybeBackWithGit ({ late = false } = {}) {
|
|
792
866
|
// A person's folder too, while git is on for everyone here and a connection covers the repository (git for everyone): with GitHub as origin.
|
|
793
|
-
if (!this.gitBacking || (this.kind !== 'agent' && !this.personGitOn()) || this.git || gitDir(this.root) || this.stopped || this.backing) return false
|
|
867
|
+
if (!this.gitBacking || (this.kind !== 'agent' && !this.personGitOn()) || this.git || gitDir(this.root) || this.gitAside || this.inSquare || this.stopped || this.backing) return false
|
|
794
868
|
// The relay's record of the branch (its repository and the commit the session is at) comes with its
|
|
795
869
|
// branch list; when it isn't here yet, setBranches tries again as it arrives (no wait at start).
|
|
796
870
|
const relay = this.relayEntryFor(this.branch)
|
|
@@ -984,6 +1058,9 @@ export class Session extends EventEmitter {
|
|
|
984
1058
|
*/
|
|
985
1059
|
async pushCheck ({ remote = 'origin', refs = [] } = {}) {
|
|
986
1060
|
const list = (Array.isArray(refs) ? refs : []).filter((r) => r && typeof r.remoteRef === 'string')
|
|
1061
|
+
// A session that is a quilt lands work through Seam, and GitHub takes only Seam's export: an
|
|
1062
|
+
// agent's push goes nowhere, and a person's to the session's repository is told the Seam way.
|
|
1063
|
+
if (this.quilt && this.quilt.id && (this.kind === 'agent' || await this.isSessionRemote(remote))) return { ok: false, why: pushRefusal(this.quilt.slug || this.quilt.id) }
|
|
987
1064
|
if (this.kind !== 'agent') return this.personPushCheck({ remote })
|
|
988
1065
|
const policy = (this.access && this.access.agentCommits) || 'branches'
|
|
989
1066
|
if (policy === 'off') return { ok: false, why: `${gitForEveryoneWords({ policy: 'off', by: this.offBy() })} Ask a person for the commit with quilt_request_commit.` }
|
|
@@ -1035,6 +1112,16 @@ export class Session extends EventEmitter {
|
|
|
1035
1112
|
return { ok: false, why: `${gitForEveryoneWords({ policy: 'off', by })} ${by ? 'Its admins turn it back on in its settings.' : 'The session owner turns it back on in the session\'s settings (under Version control, This session\'s source control).'}` }
|
|
1036
1113
|
}
|
|
1037
1114
|
|
|
1115
|
+
/** Whether `remote` (a name or URL) is the session's GitHub repository: its own folder's origin, or one its branches follow. */
|
|
1116
|
+
async isSessionRemote (remote = 'origin') {
|
|
1117
|
+
const name = String(remote || 'origin')
|
|
1118
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\/|^[^/\s]+@[^/\s]+:/i.test(name) ? name : await this.gitOut(['config', '--get', `remote.${name}.url`])
|
|
1119
|
+
if (url && url === this.relayGitUrl()) return true
|
|
1120
|
+
const repo = url ? parseRepo(url) : null
|
|
1121
|
+
if (!repo || !repo.github) return false
|
|
1122
|
+
return name === 'origin' || this.sessionRepos().has(`github.com/${repo.owner}/${repo.name}`.toLowerCase())
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1038
1125
|
/** The rule over the session that turned git off ({ by, name }), or null when the owner did (or it is on). */
|
|
1039
1126
|
offBy () {
|
|
1040
1127
|
const by = this.access && this.access.agentCommitsBy
|
|
@@ -1213,6 +1300,9 @@ export class Session extends EventEmitter {
|
|
|
1213
1300
|
if (was && was.agentCommits !== a.agentCommits) this.gitToken = null
|
|
1214
1301
|
this.syncPersonGitSoon() // git for everyone: a person's helper follows git on or off
|
|
1215
1302
|
if (a.state === 'approved' && a.owner) this.sendStartName()
|
|
1303
|
+
if (this.force && this.force.migrate && a.state === 'approved' && a.owner) this.onForce(this.force)
|
|
1304
|
+
// A new session becomes a quilt as its owner (in a room with no owner, the relay lets any editor: quiltPlanRefusal).
|
|
1305
|
+
if (a.state === 'approved' && a.role !== 'viewer' && (a.owner || !a.controlled)) this.startQuiltOnce()
|
|
1216
1306
|
if (a.state === 'approved' && this.webhook && !this.webhook.local) this.migrateWebhook().catch(() => {}) // the relay sends it from now on (or again, if it lost it)
|
|
1217
1307
|
if (a.state === 'pending' && (!was || was.state !== 'pending')) this.log(`⏳ waiting for the session owner to let you in (you were invited to ${a.invitedAs === 'viewer' ? 'view' : 'edit'})`)
|
|
1218
1308
|
if (a.state === 'approved' && was && (was.role !== a.role || String(was.scopes) !== String(a.scopes) || String(was.scopesExcept || []) !== String(a.scopesExcept || []))) {
|
|
@@ -1313,6 +1403,19 @@ export class Session extends EventEmitter {
|
|
|
1313
1403
|
this.rename(this.startName).catch((err) => this.log(`couldn't name the session: ${err.message}`))
|
|
1314
1404
|
}
|
|
1315
1405
|
|
|
1406
|
+
/** A new session, as its owner, starts as a quilt when the API says new sessions do (flags order 8); once, in the background. */
|
|
1407
|
+
startQuiltOnce () {
|
|
1408
|
+
if (!this.newSession || this.startQuiltTried) return
|
|
1409
|
+
this.startQuiltTried = true
|
|
1410
|
+
// Made as a person: an agent's own key only reads on Seam, so an agent's new session is made as the person signed in on this computer.
|
|
1411
|
+
const credential = async () => {
|
|
1412
|
+
const c = await sessionCredential({ apiAuth: this.apiAuth, account: readAccount })()
|
|
1413
|
+
if (c && isAgentKey(c) && !process.env.SEAM_TOKEN) { const acc = readAccount(); if (acc && acc.token) return acc.token }
|
|
1414
|
+
return c
|
|
1415
|
+
}
|
|
1416
|
+
this.startingQuilt = startAsQuilt(this, { credential, ...(this.quiltDefault || {}) })
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1316
1419
|
/** As the owner, tells the relay which workspace the session is in (once per run; again when it moves). */
|
|
1317
1420
|
sendWorkspace () {
|
|
1318
1421
|
if (!this.workspace || this.workspaceSent === this.workspace || !this.isOwner) return
|
|
@@ -1690,9 +1793,12 @@ export class Session extends EventEmitter {
|
|
|
1690
1793
|
|
|
1691
1794
|
/** Runs once the relay has synced: merges every captured path against the session's version. */
|
|
1692
1795
|
async mergeOffline ({ entries, take, downloads }) {
|
|
1796
|
+
if (this.syncStopped) { for (const e of entries) this.merging.delete(e.rel); return null }
|
|
1693
1797
|
if (this.stopped) { for (const e of entries) this.merging.delete(e.rel); return null } // the next start captures them again
|
|
1694
1798
|
// The saved copy was of an older document of this branch: the folder joined fresh instead (rejoinFresh).
|
|
1695
1799
|
if (this.staleRejoin || this.rejoinedFresh) { for (const e of entries) this.merging.delete(e.rel); return null }
|
|
1800
|
+
// The folder follows its square: the block takes the edits made while away in (they differ from lastKnown, editedSinceSync).
|
|
1801
|
+
if (this.inSquare) { for (const e of entries) this.merging.delete(e.rel); return null }
|
|
1696
1802
|
const counts = { pushed: 0, merged: 0, ai: 0, conflict: 0 }
|
|
1697
1803
|
const paths = { merged: [], conflict: [] } // for the catch-up
|
|
1698
1804
|
const queue = entries.slice()
|
|
@@ -2009,6 +2115,7 @@ export class Session extends EventEmitter {
|
|
|
2009
2115
|
|
|
2010
2116
|
/** `gitNow`: for a diff branch, what the folder has that differs from HEAD (gitstate.js changedSince), or null. */
|
|
2011
2117
|
reconcileFirstJoin (gitNow = null) {
|
|
2118
|
+
if (this.inSquare || this.syncStopped) return { pulled: 0 } // the square's block brings the folder in (session-follow.js)
|
|
2012
2119
|
const onDisk = new Set(walk(this.root, this.ig))
|
|
2013
2120
|
let pulled = 0; let pushed = 0; let backedUp = 0
|
|
2014
2121
|
const backupDir = path.join(this.stateDir, 'conflicts', new Date().toISOString().replace(/[:.]/g, '-'))
|
|
@@ -2128,6 +2235,10 @@ export class Session extends EventEmitter {
|
|
|
2128
2235
|
// ------------------------------------------------------- local -> shared --
|
|
2129
2236
|
|
|
2130
2237
|
queue (rel) {
|
|
2238
|
+
// The folder follows its square: the block sees every change; git moving HEAD may take it to another branch's square.
|
|
2239
|
+
// The app hears of each change on disk all the same (its Files list and an open file read the folder then: squareFiles).
|
|
2240
|
+
if (this.syncStopped) return // live sync stopped at the forced date (onForce): the folder is plain git
|
|
2241
|
+
if (this.inSquare) { if (rel === HEAD_CHANGED) this.squareHeadMoved(); else this.emit('file-changed', { path: rel }); return }
|
|
2131
2242
|
this.pending.add(rel)
|
|
2132
2243
|
if (!this.flushTimer) this.flushTimer = setTimeout(() => this.flushPending(), FLUSH_MS)
|
|
2133
2244
|
}
|
|
@@ -2933,7 +3044,7 @@ export class Session extends EventEmitter {
|
|
|
2933
3044
|
|
|
2934
3045
|
/** (Re)arms the settle timer: the hold ends SETTLE_MS after the last file or git event. */
|
|
2935
3046
|
settleSoon () {
|
|
2936
|
-
if (!this.hold || this.hold.kind === 'switching' || this.stopped || this.holdAwaitsSync) return
|
|
3047
|
+
if (!this.hold || this.hold.kind === 'switching' || this.stopped || this.holdAwaitsSync || this.inSquare) return
|
|
2937
3048
|
clearTimeout(this.settleTimer)
|
|
2938
3049
|
const gen = ++this.settleGen
|
|
2939
3050
|
this.settleTimer = setTimeout(() => {
|
|
@@ -3303,7 +3414,7 @@ export class Session extends EventEmitter {
|
|
|
3303
3414
|
|
|
3304
3415
|
/** Looks again shortly (a branch moved, a fetch landed); several asks in a row make one look. */
|
|
3305
3416
|
checkUpstreamSoon ({ fetch = false, delay = SETTLE_MS } = {}) {
|
|
3306
|
-
if (!this.git || !this.git.branch || this.stopped) return
|
|
3417
|
+
if (!this.git || !this.git.branch || this.stopped || this.inSquare || this.syncStopped) return
|
|
3307
3418
|
this.upstreamFetch = this.upstreamFetch || fetch
|
|
3308
3419
|
if (this.upstreamTimer) return // one look is already on its way: it takes this ask too
|
|
3309
3420
|
this.upstreamTimer = setTimeout(() => {
|
|
@@ -3332,7 +3443,7 @@ export class Session extends EventEmitter {
|
|
|
3332
3443
|
* fetching first when asked, and catches up when the branch is behind. Returns the state.
|
|
3333
3444
|
*/
|
|
3334
3445
|
async checkUpstream ({ fetch = false, now = false } = {}) {
|
|
3335
|
-
if (!this.git || !this.git.branch || this.stopped) return this.upstream
|
|
3446
|
+
if (!this.git || !this.git.branch || this.stopped || this.inSquare || this.syncStopped) return this.upstream
|
|
3336
3447
|
if (!this.quietForUpstream()) {
|
|
3337
3448
|
this.upstreamLater(fetch) // busy for a moment: asked again once it's quiet
|
|
3338
3449
|
return this.upstream
|
|
@@ -3569,7 +3680,17 @@ export class Session extends EventEmitter {
|
|
|
3569
3680
|
for (const rel of paths) {
|
|
3570
3681
|
if (this.syncable(rel) && this.writeRefusal(rel)) { state({ waiting: `you may not change ${rel} in this session` }); return }
|
|
3571
3682
|
}
|
|
3572
|
-
|
|
3683
|
+
// Links are git's (the session never syncs them): one the folder has as git has it at either end is that version, and one the commits bring is written as a link.
|
|
3684
|
+
const [linksFrom, linksTo] = [await linksAt(this.root, from, paths), await linksAt(this.root, to, paths)]
|
|
3685
|
+
if (!linksFrom || !linksTo) return
|
|
3686
|
+
const linkKey = (rel) => {
|
|
3687
|
+
let target
|
|
3688
|
+
try { target = fs.readlinkSync(resolveInside(this.root, rel)) } catch { return undefined }
|
|
3689
|
+
if (linksTo.has(rel) && theirs.get(rel) === target) return target
|
|
3690
|
+
if (linksFrom.has(rel) && base.get(rel) === target) return target
|
|
3691
|
+
return undefined
|
|
3692
|
+
}
|
|
3693
|
+
const diskKey = (rel) => { const k = this.diskKey(rel); return k === undefined ? linkKey(rel) : k }
|
|
3573
3694
|
// The session and this folder must agree first: a change still on its way waits for the next look.
|
|
3574
3695
|
// Following, that is every file the commits changed: the branch moves only once the session's versions are
|
|
3575
3696
|
// on disk, or a commit made meanwhile (git commit -a) would take a file still on its way for a deletion.
|
|
@@ -3625,7 +3746,7 @@ export class Session extends EventEmitter {
|
|
|
3625
3746
|
const data = key.startsWith('bin:') ? await blobAt(this.root, to, rel) : key
|
|
3626
3747
|
if (data === null) throw new Error('git could not read it')
|
|
3627
3748
|
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
3628
|
-
this.writeFile(rel, abs, data)
|
|
3749
|
+
if (linksTo.has(rel)) { fs.rmSync(abs, { force: true }); fs.symlinkSync(String(data), abs) } else this.writeFile(rel, abs, data)
|
|
3629
3750
|
}
|
|
3630
3751
|
written++
|
|
3631
3752
|
if (this.syncable(rel)) this.ingest(rel, { pulled: true })
|
|
@@ -3686,6 +3807,514 @@ export class Session extends EventEmitter {
|
|
|
3686
3807
|
* Asked for by an AI (quilt_sync_branch): fetch and bring commits in now, without waiting for
|
|
3687
3808
|
* the next look or another member's folder. What happened: { branch, upstream, moved, busy }.
|
|
3688
3809
|
*/
|
|
3810
|
+
/**
|
|
3811
|
+
* What making this session a quilt would do (Seam's plans/quilt-source-control/16), read only: the
|
|
3812
|
+
* relay's dry run (behind QUILT_SOURCE_URL there, the owner's), as { plan, text }.
|
|
3813
|
+
*/
|
|
3814
|
+
async quiltPlan () {
|
|
3815
|
+
if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay; the dry run needs a connection')
|
|
3816
|
+
const r = await this.conn.branchRequest({ op: 'quilt-plan' })
|
|
3817
|
+
return { plan: r.plan, text: describeQuiltPlan(r.plan) }
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
/**
|
|
3821
|
+
* Makes the session a quilt (Seam's plans/quilt-source-control/16, "Without history"): the relay does
|
|
3822
|
+
* it, as the owner, with `token` (their Seam sign-in: SEAM_TOKEN, else their Quilt account's).
|
|
3823
|
+
* What it made: { quilt, text }.
|
|
3824
|
+
*/
|
|
3825
|
+
async quiltMigrate (token, { op = 'quilt-migrate' } = {}) {
|
|
3826
|
+
if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay; making the session a quilt needs a connection')
|
|
3827
|
+
if (!token) throw new Error('sign in first (quilt login, or set SEAM_TOKEN): the session becomes a quilt as its owner')
|
|
3828
|
+
const r = await this.conn.branchRequest({ op, token }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS })
|
|
3829
|
+
const q = r.quilt
|
|
3830
|
+
// A session with a git remote moves in steps (the relay's quiltMakeWithRemote): Seam importing it, then ready for the cut-over.
|
|
3831
|
+
if (q && (q.stage === 'importing' || q.stage === 'ready')) return { quilt: q, text: q.text }
|
|
3832
|
+
const lines = [`Session ${this.room || ''} is now the quilt ${q.slug}.`.replace('Session is', 'This session is')]
|
|
3833
|
+
if (q.history) lines.push(`It is the quilt its git remote was imported into: main is at patch ${q.patch && q.patch.no}, git's history before it.`)
|
|
3834
|
+
else if (q.patch) lines.push(`Patch ${q.patch.no} holds the main branch as it was.`)
|
|
3835
|
+
for (const s of q.squares) lines.push(`Branch ${s.from} is square ${squareShort(s.id)}.`)
|
|
3836
|
+
for (const b of q.based_at_tip || []) {
|
|
3837
|
+
lines.push(b.why === 'unfetched'
|
|
3838
|
+
? `The relay never fetched ${b.from} from git, so its square starts at main's tip with the branch's files over it: its history before the move is main's, not the branch's.`
|
|
3839
|
+
: b.why === 'merged'
|
|
3840
|
+
? `${b.from}'s commit ${String(b.wanted || '').replace(/^git:/, '').slice(0, 12)} is already merged into main, so its square starts at main's tip.`
|
|
3841
|
+
: `Seam has no commit for ${String(b.wanted || '').replace(/^git:/, '')}, so ${b.from}'s square starts at main's tip.`)
|
|
3842
|
+
}
|
|
3843
|
+
if (q.adoption) lines.push(`Work on the main branch that git does not have is in the square "Uncommitted work" (${squareShort(q.adoption)}).`)
|
|
3844
|
+
if (q.read_token) lines.push(readTokenLine(q.read_token))
|
|
3845
|
+
if (q.large) lines.push(largeLine(q.large))
|
|
3846
|
+
if (q.rebased) lines.push(`Someone's unfinished work boxed on an older main went onto main's newer copy as their change only, not their whole file: ${q.rebased.slice(0, 8).map((x) => `${x.path} (${x.branch})`).join(', ')}.`)
|
|
3847
|
+
if (q.claims && q.claims.left && q.claims.left.length) lines.push(`Claims not moved: ${q.claims.left.map((c) => `${c.pattern} (${c.why})`).join(', ')}.`)
|
|
3848
|
+
if (q.layout === 3) lines.push('Its files are in its squares now; apps without Seam are asked to update. quilt session migrate --back goes back to git.')
|
|
3849
|
+
lines.push(...notSharedLines(q.not_shared))
|
|
3850
|
+
const moved = Object.keys(q.tasks || {}).length
|
|
3851
|
+
if (moved) lines.push(`${moved} task${moved === 1 ? '' : 's'} with a pull request follow${moved === 1 ? 's' : ''} its square.`)
|
|
3852
|
+
if (q.left_out.length) lines.push(`Large files left in the session's storage, not in the quilt: ${q.left_out.join(', ')}.`)
|
|
3853
|
+
return { quilt: q, text: lines.join('\n') }
|
|
3854
|
+
}
|
|
3855
|
+
|
|
3856
|
+
/**
|
|
3857
|
+
* The relay's word on the forced date (16 flag 9). `stopped`: the session had a git remote and did
|
|
3858
|
+
* not cut over, so its live file sync stopped for good: this folder is plain git from now on (no
|
|
3859
|
+
* file is written or shared by Quilt, no git work of Quilt's), said once in the log, the app and
|
|
3860
|
+
* every quilt answer. `migrate`: a session with no git remote, past the date, opened by its owner:
|
|
3861
|
+
* made a quilt now, as they would with the button (once per run of the app).
|
|
3862
|
+
*/
|
|
3863
|
+
onForce (f) {
|
|
3864
|
+
this.force = f || null
|
|
3865
|
+
if (f && f.stopped && !this.syncStopped) {
|
|
3866
|
+
this.syncStopped = { at: f.stopped.at }
|
|
3867
|
+
clearTimeout(this.flushTimer)
|
|
3868
|
+
this.flushTimer = null
|
|
3869
|
+
this.pending.clear()
|
|
3870
|
+
clearTimeout(this.upstreamTimer)
|
|
3871
|
+
this.upstreamTimer = null
|
|
3872
|
+
const why = String(f.stopped.why || 'Live sync stopped: this session\'s folder is plain git now.')
|
|
3873
|
+
this.log(`🧵 ${why}`)
|
|
3874
|
+
this.notice(why)
|
|
3875
|
+
this.scheduleStatusWrite()
|
|
3876
|
+
this.emit('force', f)
|
|
3877
|
+
return
|
|
3878
|
+
}
|
|
3879
|
+
if (f && f.migrate && !f.remote && !this.forceMigrateTried && this.access && this.access.owner) {
|
|
3880
|
+
const token = process.env.SEAM_TOKEN || (readAccount() || {}).token
|
|
3881
|
+
if (!token) return
|
|
3882
|
+
this.forceMigrateTried = true
|
|
3883
|
+
this.log('🧵 The date for moving sessions into Seam has passed: making this session a quilt now (its code is committed in Seam from here on; nothing is lost).')
|
|
3884
|
+
this.quiltMake(token).then((r) => { this.log(`🧵 ${r.text.split('\n')[0]}`); this.notice(r.text) }, (err) => this.log(`⚠️ couldn't make the session a quilt at the forced date: ${err.message}. Settings → Session → Make this a quilt tries again.`))
|
|
3885
|
+
}
|
|
3886
|
+
this.emit('force', f)
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
/**
|
|
3890
|
+
* "Make this a quilt" (16 flag 9), the owner's button and `quilt session migrate`: the relay makes the
|
|
3891
|
+
* session a quilt and moves its files into its squares in one go (Room.quiltMake). { quilt, text }.
|
|
3892
|
+
*/
|
|
3893
|
+
quiltMake (token) { return this.quiltMigrate(token, { op: 'quilt-make' }) }
|
|
3894
|
+
|
|
3895
|
+
/**
|
|
3896
|
+
* The session's files into its squares' live documents on the relay (layout 3, 16), as the
|
|
3897
|
+
* owner, once it is a quilt: { layout, live, adoption, text }.
|
|
3898
|
+
*/
|
|
3899
|
+
async quiltSwitch (token) {
|
|
3900
|
+
if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay; moving the session needs a connection')
|
|
3901
|
+
if (!token) throw new Error('sign in first (quilt login, or set SEAM_TOKEN): the session moves into its squares as its owner')
|
|
3902
|
+
const r = await this.conn.branchRequest({ op: 'quilt-switch', token }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS })
|
|
3903
|
+
const lines = ['The session\'s files are in its squares now; its branches are kept, read-only.']
|
|
3904
|
+
for (const [b, k] of Object.entries(r.live || {})) lines.push(`${b} is in square ${squareShort(k.replace(/^quilt\//, ''))}.`)
|
|
3905
|
+
if (r.adoption) lines.push(`Work on the main branch since it became a quilt is in the square "Uncommitted work" (${squareShort(r.adoption)}).`)
|
|
3906
|
+
if (r.read_token) lines.push(readTokenLine(r.read_token))
|
|
3907
|
+
if (r.large) lines.push(largeLine(r.large))
|
|
3908
|
+
if (r.rebased) lines.push(`Someone's unfinished work boxed on an older main went onto main's newer copy as their change only, not their whole file: ${r.rebased.slice(0, 8).map((x) => `${x.path} (${x.branch})`).join(', ')}.`)
|
|
3909
|
+
lines.push('Apps without Seam are asked to update. quilt session migrate --back goes back to the branches.')
|
|
3910
|
+
lines.push(...notSharedLines(r.not_shared))
|
|
3911
|
+
return { ...r, text: lines.join('\n') }
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3914
|
+
/**
|
|
3915
|
+
* At layout 3 (Seam's plans/quilt-source-control/16): `dir` (its own folder) follows this folder's
|
|
3916
|
+
* branch's square, live (session-follow.js), signed in to Seam with `token` (else as this member). Only with
|
|
3917
|
+
* Seam on for this app (seam-url.js) and the relay saying the session is a layout-3 quilt.
|
|
3918
|
+
*/
|
|
3919
|
+
async quiltFollow (token, dir) {
|
|
3920
|
+
if (!this.squareFollower) this.squareFollower = new SquareFollower(this)
|
|
3921
|
+
// As `token`, else as this member (seamMember), the same as `quilt commit` and the Seam tools.
|
|
3922
|
+
const client = !appSeamUrl() ? null : token ? quiltClient({ api: appSeamUrl(), token }) : await this.seamClientNow()
|
|
3923
|
+
const r = await this.squareFollower.follow({ dir: dir ? path.resolve(this.root, String(dir)) : '', client })
|
|
3924
|
+
return { ...r, text: `${r.dir} follows the square ${squareShort(r.square)}: edits there are live for everyone on it.` }
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
/**
|
|
3928
|
+
* The folder follows its branch's square in place (session-follow.js, at layout 3): from now on
|
|
3929
|
+
* its block takes every change, so the session's own sync of the folder holds: nothing goes to
|
|
3930
|
+
* the read-only branch document, and no git-mode upstream work runs (no fetch, fast-forward or
|
|
3931
|
+
* merge into uncommitted work, no boxes). Called before the block opens, so nothing slips through.
|
|
3932
|
+
*/
|
|
3933
|
+
enterSquare (at) {
|
|
3934
|
+
if (this.inSquare && this.inSquare.square === at.square) return
|
|
3935
|
+
this.inSquare = { square: at.square, since: Date.now() }
|
|
3936
|
+
clearTimeout(this.flushTimer)
|
|
3937
|
+
this.flushTimer = null
|
|
3938
|
+
this.pending.clear()
|
|
3939
|
+
this.setGitAside()
|
|
3940
|
+
this.scheduleStatusWrite()
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
/** In a quilt nobody uses git: the folder's .git goes into .quilt while it follows its square (session-follow.js), and git-mode work stops. */
|
|
3944
|
+
setGitAside () {
|
|
3945
|
+
let r
|
|
3946
|
+
try { r = setGitAside(this.root, { branch: this.branchKey() }) } catch (err) { this.log(`⚠️ couldn't set git aside in this folder (${err.message}); git still works here, but the session's code history is in Seam`); return }
|
|
3947
|
+
if (r.both) this.log('⚠️ a new .git appeared in this folder while it follows its square; the one set aside is kept in .quilt/git-before-seam')
|
|
3948
|
+
if (r.moved || r.already) this.gitAside = gitAsideOf(this.root)
|
|
3949
|
+
if (r.moved) this.log('🧵 git is set aside in this folder while it follows its square (in .quilt/git-before-seam): in a quilt, Seam keeps the history. It comes back if the session goes back to its branches.')
|
|
3950
|
+
if (r.worktrees) {
|
|
3951
|
+
const n = r.worktrees.length
|
|
3952
|
+
this.log(`🧵 ${n} other worktree${n === 1 ? '' : 's'} of this repository (${r.worktrees.slice(0, 5).join(', ')}${n > 5 ? `, and ${n - 5} more` : ''}) keep using git while this folder follows its square: each one's .git file points at this folder's .git in .quilt/git-before-seam meanwhile, and goes back as it was with it.`)
|
|
3953
|
+
}
|
|
3954
|
+
if (this.gitWatcher) { const w = this.gitWatcher; this.gitWatcher = null; Promise.resolve(w.close()).catch(() => {}) }
|
|
3955
|
+
clearInterval(this.upstreamPoll)
|
|
3956
|
+
this.upstreamPoll = null
|
|
3957
|
+
this.git = null
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
/** The .git set aside comes back (before git-mode sync resumes), and git is watched again. */
|
|
3961
|
+
async gitBack () {
|
|
3962
|
+
if (!gitAsideOf(this.root)) { this.gitAside = null; return false }
|
|
3963
|
+
let back = false
|
|
3964
|
+
try { back = putGitBack(this.root) } catch (err) { this.log(`⚠️ couldn't put git back in this folder (${err.message}); it is in .quilt/git-before-seam`); return false }
|
|
3965
|
+
if (!back) { this.log('⚠️ this folder has a new .git, so the one set aside stays in .quilt/git-before-seam'); return false }
|
|
3966
|
+
this.gitAside = null
|
|
3967
|
+
this.git = await headKey(this.root)
|
|
3968
|
+
this.gitSeen = this.git
|
|
3969
|
+
if (this.git && !this.stopped) this.startGitWatch()
|
|
3970
|
+
this.log('git is back in this folder.')
|
|
3971
|
+
this.scheduleStatusWrite()
|
|
3972
|
+
return true
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3975
|
+
/**
|
|
3976
|
+
* Back from the square (the session went back to layout 2): the old sync resumes. What the folder
|
|
3977
|
+
* holds now that differs from the branch document (the square's work, the same on every member's
|
|
3978
|
+
* folder) is shared as one edit from all of them (same-edit.js), then git-mode work carries on.
|
|
3979
|
+
* `writers` (path -> name, who last wrote it in the square: live-square.js lastWriters): only that
|
|
3980
|
+
* member's folder boxes a returning file. Whichever folder ingested it first used to, so a member
|
|
3981
|
+
* who reached the session first held another's work, and the writer's next edit reached nobody.
|
|
3982
|
+
*/
|
|
3983
|
+
async leaveSquare ({ writers = null } = {}) {
|
|
3984
|
+
if (!this.inSquare) return
|
|
3985
|
+
this.unfollowSquareDoc()
|
|
3986
|
+
// git first: git-mode sync resumes on a folder git knows again.
|
|
3987
|
+
await this.gitBack()
|
|
3988
|
+
this.inSquare = null
|
|
3989
|
+
// The block's record of the folder goes too: after branches, a later switch starts from the session's sync, not from it.
|
|
3990
|
+
try { fs.rmSync(path.join(this.stateDir, 'block.json'), { force: true }) } catch {}
|
|
3991
|
+
const moved = this.squareBranch && this.squareBranch !== this.branchKey()
|
|
3992
|
+
this.squareBranch = null
|
|
3993
|
+
if (moved) this.queue(HEAD_CHANGED)
|
|
3994
|
+
else {
|
|
3995
|
+
for (const rel of new Set([...walk(this.root, this.ig), ...this.lastKnown.keys()])) {
|
|
3996
|
+
if (!this.syncable(rel) || this.untouchedHere(rel)) continue
|
|
3997
|
+
try { this.ingest(rel, { same: true, claim: !!(writers && writers.get(rel) === this.name) }) } catch (err) { this.log(`could not sync ${rel}: ${err.message}`) }
|
|
3998
|
+
}
|
|
3999
|
+
}
|
|
4000
|
+
this.scanDisk({ baseline: true })
|
|
4001
|
+
this.checkUpstreamSoon({ fetch: true })
|
|
4002
|
+
this.settleSoon()
|
|
4003
|
+
this.scheduleStatusWrite()
|
|
4004
|
+
}
|
|
4005
|
+
|
|
4006
|
+
/**
|
|
4007
|
+
* Whether the folder's copy of `rel` changed since the session last synced it (for the square's
|
|
4008
|
+
* first look at the folder, live-block.js adopt). A folder never synced has no edits of its own.
|
|
4009
|
+
*/
|
|
4010
|
+
editedSinceSync (rel) {
|
|
4011
|
+
if (!this.lastKnown.size) return false
|
|
4012
|
+
const k = this.diskKey(rel)
|
|
4013
|
+
if (k === undefined) return true // a link, or too large for the session: the folder's own
|
|
4014
|
+
return this.lastKnown.has(rel) ? k !== this.lastKnown.get(rel) : k !== null
|
|
4015
|
+
}
|
|
4016
|
+
|
|
4017
|
+
/**
|
|
4018
|
+
* Whether `rel` was someone else's box in this folder before the move (ghost): the folder kept its own
|
|
4019
|
+
* copy (git's, moved on by pulls) while the session held the box's, so the square's first look puts the
|
|
4020
|
+
* box's change onto the folder's copy (live-block.js combineAway), from what everyone had when it began.
|
|
4021
|
+
*/
|
|
4022
|
+
boxedHere (rel) { return this.boxBase.has(rel) }
|
|
4023
|
+
|
|
4024
|
+
/**
|
|
4025
|
+
* Whether the session held `rel` back from this folder before the move: someone else's box here (the
|
|
4026
|
+
* folder kept git's copy, or none for a new file), or a change still waiting for whoever held the file
|
|
4027
|
+
* (an open merge record: deleted or changed offline while another held it). The folder's lack of it
|
|
4028
|
+
* at the square's first look is then not its deletion.
|
|
4029
|
+
*/
|
|
4030
|
+
heldBackHere (rel) {
|
|
4031
|
+
if (this.boxBase.has(rel)) return true
|
|
4032
|
+
try { return this.mergeList().some((m) => m && m.path === rel && m.state !== 'done') } catch { return false }
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
/** Whether the session synced `rel` with this folder (the square's first look takes a file it has as git or the session left it for no edit). */
|
|
4036
|
+
syncedHere (rel) {
|
|
4037
|
+
if (!this.lastKnown.has(rel) || this.boxBase.has(rel)) return false
|
|
4038
|
+
// A large file in the session's storage is not in the square (the relay can't read its bytes, so the
|
|
4039
|
+
// move left it out and named it): the folder's copy is what puts it there, never the square's absence.
|
|
4040
|
+
const b = this.blobs && this.blobs.get(rel)
|
|
4041
|
+
return !(b && b.stored)
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
/** The paths this folder's branch changed since it left the default branch, { committed, uncommitted } (the square's first look keeps those as the folder's), or null when git can't say. */
|
|
4045
|
+
branchOwnPaths () {
|
|
4046
|
+
return ownSinceFork(this.root, gitAsideOf(this.root) ? { gitDir: path.join(this.root, GIT_ASIDE) } : {}).catch(() => null)
|
|
4047
|
+
}
|
|
4048
|
+
|
|
4049
|
+
/**
|
|
4050
|
+
* The paths whose edits this folder made into its branch document but the relay does not hold
|
|
4051
|
+
* (session-follow.js unsentPaths): at layout 3 the read-only document refused them, so the
|
|
4052
|
+
* folder's copy is its own work, never the switch's older copy. Empty with nothing refused;
|
|
4053
|
+
* null when the relay can't say (an older relay).
|
|
4054
|
+
*/
|
|
4055
|
+
async unsentPaths () {
|
|
4056
|
+
if (!this.bdoc || !this.conn || !this.conn.connected || this.conn.branchKey === null) return null
|
|
4057
|
+
const r = await this.conn.branchRequest({ op: 'state', branch: this.conn.branchKey })
|
|
4058
|
+
const bytes = (b64) => new Uint8Array(Buffer.from(String(b64 || ''), 'base64'))
|
|
4059
|
+
return unsentPaths(this.bdoc, bytes(r.sv), bytes(r.deleted))
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
/**
|
|
4063
|
+
* The folder's block on its square is open: who changed what (the chronology, Changes) is kept in
|
|
4064
|
+
* the square's live document from now on. It came with the branch's at the switch and goes on from
|
|
4065
|
+
* square to square (session-layout3.js copyIntoLive, carryLive), so a file's History, Changes and
|
|
4066
|
+
* the Files tree carry on where the branch left them.
|
|
4067
|
+
*/
|
|
4068
|
+
followSquareDoc (doc) {
|
|
4069
|
+
if (!doc || this.squareDoc === doc) return
|
|
4070
|
+
this.squareDoc = doc
|
|
4071
|
+
this.history = new HistoryLog(doc, doc.getArray('history'), { origin: LOCAL })
|
|
4072
|
+
this.tallies = doc.getMap('changes')
|
|
4073
|
+
this.scheduleStatusWrite()
|
|
4074
|
+
}
|
|
4075
|
+
|
|
4076
|
+
/** Back on the branch's document (the block closed). */
|
|
4077
|
+
unfollowSquareDoc () {
|
|
4078
|
+
if (!this.squareDoc) return
|
|
4079
|
+
this.squareDoc = null
|
|
4080
|
+
if (this.bdoc) { this.history = new HistoryLog(this.bdoc, this.bdoc.getArray('history'), { origin: LOCAL }); this.tallies = this.bdoc.getMap('changes') }
|
|
4081
|
+
this.scheduleStatusWrite()
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
/**
|
|
4085
|
+
* Edits this folder made in its square, as its block took them in (live-block.js onEdits): on
|
|
4086
|
+
* record as this member's, the same as on a branch (recordActivity: the chronology and Changes in
|
|
4087
|
+
* the square's live document `doc`, the room's activity, this member's presence).
|
|
4088
|
+
*/
|
|
4089
|
+
squareEdits (list, doc, held = null) {
|
|
4090
|
+
this.followSquareDoc(doc)
|
|
4091
|
+
for (const e of list) {
|
|
4092
|
+
const texts = typeof e.before === 'string' || typeof e.after === 'string' ? { before: e.before, after: e.after } : undefined
|
|
4093
|
+
try { this.recordActivity(e.path, e.kind, e.detail || '', texts, { activity: false }) } catch (err) { this.emit('debug', `could not record ${e.path}: ${err.message}`) }
|
|
4094
|
+
this.noteMyEdit(e.path)
|
|
4095
|
+
// Claims follow edits here too (as ingest makes them on a branch): the room's claims on this
|
|
4096
|
+
// branch, which the Loom stitches, quilt_before_edit answers and the file queue hang on.
|
|
4097
|
+
const claim = this.claimFor(e.path)
|
|
4098
|
+
if (e.kind !== 'deleted' && (!claim || this.autoClaims.has(e.path)) && this.aiMayBeEditing()) this.autoClaim(e.path)
|
|
4099
|
+
}
|
|
4100
|
+
// The room's activity names a file the relay finds in the square (server.js onBranch): sent once the relay holds the edit, over the block's connection.
|
|
4101
|
+
const push = () => { if (this.stopped) return; for (const e of list) { try { this.pushActivity(e.path, e.kind, e.detail || '') } catch {} } }
|
|
4102
|
+
if (held) held().then(push, push); else push()
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
/**
|
|
4106
|
+
* The text the session last synced for `rel` (the square's first look combines an edit made while
|
|
4107
|
+
* away with the square's), or null: a file a diff branch leaves to git is git's, read from
|
|
4108
|
+
* the .git set aside.
|
|
4109
|
+
*/
|
|
4110
|
+
async syncedText (rel) {
|
|
4111
|
+
// Someone else's box: what everyone had when it began (ghost), not the folder's own copy since.
|
|
4112
|
+
if (this.boxBase.has(rel)) { const b = this.boxBase.get(rel); return b && typeof b.text === 'string' ? b.text : null }
|
|
4113
|
+
const k = this.lastKnown.get(rel)
|
|
4114
|
+
if (k !== undefined) return typeof k === 'string' && !/^bin:[0-9a-f]{40}$/.test(k) ? k : null
|
|
4115
|
+
if (!this.followsGit(rel) || !gitAsideOf(this.root)) return null
|
|
4116
|
+
const buf = await blobAt(this.root, 'HEAD', rel, { gitDir: path.join(this.root, GIT_ASIDE) })
|
|
4117
|
+
return buf && !looksBinary(buf) ? buf.toString('utf8') : null
|
|
4118
|
+
}
|
|
4119
|
+
|
|
4120
|
+
/** Whether the session never syncs anything in folder `rel` (.git, .quilt, node_modules, ignored ones): the block's walk skips it. */
|
|
4121
|
+
ignoredDir (rel) { return !isSafeRelPath(rel) || isIgnored(this.ig, rel) || this.ig.ignores(`${rel}/`) }
|
|
4122
|
+
|
|
4123
|
+
/** The branch the folder is on for its square: git's, once git moved it while following (squareHeadMoved). */
|
|
4124
|
+
squareBranchKey () { return this.squareBranch || this.branchKey() }
|
|
4125
|
+
|
|
4126
|
+
/** git moved HEAD while the folder follows a square: on another branch, the folder follows that branch's square. */
|
|
4127
|
+
squareHeadMoved () {
|
|
4128
|
+
clearTimeout(this.squareHeadTimer)
|
|
4129
|
+
this.squareHeadTimer = setTimeout(async () => {
|
|
4130
|
+
const head = await headKey(this.root).catch(() => null)
|
|
4131
|
+
if (!head || !this.inSquare || this.stopped) return
|
|
4132
|
+
const key = head.key === DEFAULT_KEY ? this.branchKey() : head.key
|
|
4133
|
+
if (key === this.squareBranchKey()) return
|
|
4134
|
+
this.squareBranch = key
|
|
4135
|
+
if (this.squareFollower) await this.squareFollower.branchMoved(this.quilt).catch((err) => this.log(`🧵 ${err.message}`))
|
|
4136
|
+
}, SETTLE_MS)
|
|
4137
|
+
this.squareHeadTimer.unref?.()
|
|
4138
|
+
}
|
|
4139
|
+
|
|
4140
|
+
/** Reads of the quilt for the folder's block: through the relay, with the room's read token (session-follow.js relayReader). */
|
|
4141
|
+
squareReader () { return relayReader(this.conn) }
|
|
4142
|
+
|
|
4143
|
+
/**
|
|
4144
|
+
* This member to Seam (seam-access.js): SEAM_TOKEN, else the Quilt sign-in the session runs as
|
|
4145
|
+
* (its person's, or its agent's key traded for an agent token). The Seam tools (control.js), the
|
|
4146
|
+
* folder's block and `quilt commit` all use this one, so they act as the same member.
|
|
4147
|
+
*/
|
|
4148
|
+
seamMember () {
|
|
4149
|
+
if (!this.seam) this.seam = seamAccess({ api: () => this.seamApiUrl ?? appSeamUrl(), credential: () => sessionCredential({ apiAuth: this.apiAuth, account: readAccount })() })
|
|
4150
|
+
return this.seam
|
|
4151
|
+
}
|
|
4152
|
+
|
|
4153
|
+
/** A Seam client as this member for the square the folder follows (seamMember), for uploads and commits; throws with why when there is none. */
|
|
4154
|
+
async seamClientNow () {
|
|
4155
|
+
if (!this.quilt || !this.quilt.id) throw new Error('This session is not a quilt.')
|
|
4156
|
+
const f = this.squareFollower
|
|
4157
|
+
const square = (f && f.here && f.here.square) || (f && f.following && f.following.square) || null
|
|
4158
|
+
return (await this.seamMember().client(this.quilt.id, square ? [square] : [])).client
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4161
|
+
/** The same, or null without one (the block reads through the relay meanwhile). */
|
|
4162
|
+
async seamWriteClient () {
|
|
4163
|
+
try { return await this.seamClientNow() } catch { return null }
|
|
4164
|
+
}
|
|
4165
|
+
|
|
4166
|
+
/** `quilt commit`: the square's live work, committed from this folder (session-follow.js). */
|
|
4167
|
+
async squareCommit (message) {
|
|
4168
|
+
if (!this.squareFollower) throw new Error('This folder does not follow a square: the session is not a quilt at layout 3.')
|
|
4169
|
+
// As this member, the same as seam_commit's (a cloud AI's agent token renews itself).
|
|
4170
|
+
if (this.squareFollower.here && this.squareFollower.here.block) this.squareFollower.here.block.client = await this.seamClientNow()
|
|
4171
|
+
const r = await this.squareFollower.commit({ message })
|
|
4172
|
+
if (r && r.nothing) return { nothing: true, text: 'Nothing to commit: the square has no work since its last commit.' }
|
|
4173
|
+
const head = r && r.square && r.square.head
|
|
4174
|
+
return { ...r, text: `Committed to square ${squareShort(this.squareFollower.here.square)}${head ? ` as ${String(head).slice(0, 12)}` : ''}.` }
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
/**
|
|
4178
|
+
* { branch } when this folder's branch has no square in the quilt at layout 3 (a branch made after the
|
|
4179
|
+
* switch, or main with nothing to adopt): its edits reach nobody until one is opened for it. Else null.
|
|
4180
|
+
*/
|
|
4181
|
+
squareless () {
|
|
4182
|
+
if (!this.quilt || this.quilt.layout !== 3 || !this.root) return null
|
|
4183
|
+
const branch = this.squareBranchKey()
|
|
4184
|
+
return squareFor(this.quilt, branch) ? null : { branch }
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
/**
|
|
4188
|
+
* The app's Open a square (status `squareless`): the same ask the first refused write makes
|
|
4189
|
+
* (openBranchSquare, the relay's quilt-open, as this member), with what went wrong said, not only
|
|
4190
|
+
* logged. { square, text }.
|
|
4191
|
+
*/
|
|
4192
|
+
async openSquareHere () {
|
|
4193
|
+
if (!this.quilt || this.quilt.layout !== 3 || !this.quilt.id) throw new Error('This session\'s code is not in Seam.')
|
|
4194
|
+
if (!this.conn || !this.conn.connected) throw new Error('This folder is not connected to the relay; try again in a moment.')
|
|
4195
|
+
const branch = this.squareBranchKey()
|
|
4196
|
+
const at = squareFor(this.quilt, branch)
|
|
4197
|
+
if (at) return { square: at.square, text: `${branch} has a square already: ${squareShort(at.square)}.` }
|
|
4198
|
+
if (this.openingSquare) throw new Error(`A square for ${branch} is being opened already; your folder follows it in a moment.`)
|
|
4199
|
+
let r
|
|
4200
|
+
try { r = await this.openBranchSquare(branch, { quiet: false }) } catch (err) {
|
|
4201
|
+
// A branch the relay never had (checked out in git after the move): nothing to start its square from.
|
|
4202
|
+
if (/is not one of this session's branches/.test(err.message)) throw new Error(`${branch} was made after the session's code moved into Seam, so the session has nothing of it to start a square from. Check out a branch the session has (main, say) and your folder follows that branch's square; or ask the session's owner.`)
|
|
4203
|
+
throw err
|
|
4204
|
+
}
|
|
4205
|
+
return { square: r.square, text: r.opened ? `Opened square ${squareShort(r.square)} for ${branch}: your folder follows it in a moment, with your files as its work, and your edits reach everyone on it.` : `${branch} has a square already: ${squareShort(r.square)}; your folder follows it in a moment.` }
|
|
4206
|
+
}
|
|
4207
|
+
|
|
4208
|
+
/**
|
|
4209
|
+
* The relay refused a write of this folder's to a read-only branch document (layout 3): said in
|
|
4210
|
+
* the log, kept for status (quilt_status, the app), and told to this member's AI at its next call.
|
|
4211
|
+
*/
|
|
4212
|
+
noteReadOnly (r) {
|
|
4213
|
+
if (!r || !r.why) return
|
|
4214
|
+
this.readOnly = { branch: r.branch || '', ...(r.square ? { square: String(r.square) } : {}), why: String(r.why).slice(0, 500), at: Date.now() }
|
|
4215
|
+
this.log(`⚠️ ${this.readOnly.why}`)
|
|
4216
|
+
this.notice(this.readOnly.why)
|
|
4217
|
+
this.scheduleStatusWrite()
|
|
4218
|
+
if (r.noSquare) this.openBranchSquare(r.branch || this.branchKey()).catch(() => {})
|
|
4219
|
+
}
|
|
4220
|
+
|
|
4221
|
+
/**
|
|
4222
|
+
* The session owner's app renews the room's read token (every folder reads its square with it)
|
|
4223
|
+
* when the relay says it is missing, refused, or ends within a month: the relay holds no
|
|
4224
|
+
* credential that can (server.js readTokenState), the owner's sign-in can. Checked whenever the
|
|
4225
|
+
* relay says something of the quilt and every few hours; tried at most once an hour.
|
|
4226
|
+
*/
|
|
4227
|
+
async renewReadToken () {
|
|
4228
|
+
const q = this.quilt
|
|
4229
|
+
if (!q || q.layout !== 3 || !this.isOwner || !this.conn || !this.conn.connected || !appSeamUrl() || this.stopped) return null
|
|
4230
|
+
if (!this.readRenewTimer) { this.readRenewTimer = setInterval(() => this.renewReadToken().catch(() => {}), READ_RENEW_CHECK_MS); this.readRenewTimer.unref?.() }
|
|
4231
|
+
const r = q.read || {}
|
|
4232
|
+
if (!(r.renew || (r.ends && r.ends - Date.now() < READ_RENEW_BEFORE_MS))) return null
|
|
4233
|
+
if (this.readRenewAt && Date.now() - this.readRenewAt < READ_RENEW_RETRY_MS) return null
|
|
4234
|
+
this.readRenewAt = Date.now()
|
|
4235
|
+
try {
|
|
4236
|
+
const { token } = await this.seamMember().client(q.id, [])
|
|
4237
|
+
const a = await this.conn.branchRequest({ op: 'quilt-shadow', token, quilt: q.slug || q.id }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS })
|
|
4238
|
+
if (a.read && a.read.renewed) this.log(`🧵 made a new read token for ${q.slug || q.id}${a.read.until ? ` (until ${a.read.until.slice(0, 10)})` : ''}: every folder reads its square with it.`)
|
|
4239
|
+
return a
|
|
4240
|
+
} catch (err) {
|
|
4241
|
+
this.log(`⚠️ couldn't renew the session's read token for ${q.slug || q.id} (${err.message}); folders can't read their squares without it. quilt session migrate --shadow ${q.slug || q.id} tries again.`)
|
|
4242
|
+
return null
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
/**
|
|
4247
|
+
* The folder's branch has no square at layout 3 (the relay kept its edits in the branch's
|
|
4248
|
+
* document): one is opened for it as this member (server.js quiltOpen), with the document and
|
|
4249
|
+
* those edits, and the folder follows it when the relay says so. One ask at a time. `quiet: false`
|
|
4250
|
+
* (the app's button, openSquareHere): a failure throws instead of only being logged.
|
|
4251
|
+
*/
|
|
4252
|
+
async openBranchSquare (branch, { quiet = true, token: given = null } = {}) {
|
|
4253
|
+
if (this.openingSquare || !this.quilt || !this.quilt.id || !this.conn || !this.conn.connected || !appSeamUrl()) {
|
|
4254
|
+
if (quiet) return null
|
|
4255
|
+
throw new Error(!appSeamUrl() ? 'Seam is turned off for this app (QUILT_SOURCE_URL=off).' : 'This folder is not connected to the relay; try again in a moment.')
|
|
4256
|
+
}
|
|
4257
|
+
this.openingSquare = true
|
|
4258
|
+
try {
|
|
4259
|
+
// `token`: the owner's sign-in a new session was made a quilt with (quilt-default.js).
|
|
4260
|
+
const token = given || (await this.seamMember().client(this.quilt.id, [])).token
|
|
4261
|
+
const r = await this.conn.branchRequest({ op: 'quilt-open', token, branch }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS })
|
|
4262
|
+
if (r.opened) this.log(`🧵 opened square ${squareShort(r.square)} for ${branch}, which had none: this folder follows it, with your edits.`)
|
|
4263
|
+
// What is on disk is the new square's work: the folder is never written over as it starts to follow it (session-follow.js openHere).
|
|
4264
|
+
this.folderWins = r.square
|
|
4265
|
+
this.readOnly = null
|
|
4266
|
+
this.scheduleStatusWrite()
|
|
4267
|
+
return r
|
|
4268
|
+
} catch (err) {
|
|
4269
|
+
if (!quiet) throw err
|
|
4270
|
+
this.log(`⚠️ couldn't open a square for ${branch} (${err.message}); your edits are kept in the session, and the next one asks again`)
|
|
4271
|
+
return null
|
|
4272
|
+
} finally { this.openingSquare = false }
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
/**
|
|
4276
|
+
* 16 step 1: links the session to the quilt an import of its git remote fills (`quilt`; null
|
|
4277
|
+
* unlinks), as the owner with `token`. The session stays as it is; quilt_history and the app
|
|
4278
|
+
* read the quilt's log from then on.
|
|
4279
|
+
*/
|
|
4280
|
+
async quiltShadowLink (token, quilt) {
|
|
4281
|
+
if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay; linking the session needs a connection')
|
|
4282
|
+
if (!token) throw new Error('sign in first (quilt login, or set SEAM_TOKEN): the session is linked as its owner')
|
|
4283
|
+
const r = await this.conn.branchRequest({ op: 'quilt-shadow', token, quilt: quilt || null }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS })
|
|
4284
|
+
if (r.read) {
|
|
4285
|
+
const name = r.shadow.slug || r.shadow.id
|
|
4286
|
+
const until = r.read.until ? ` (until ${r.read.until.slice(0, 10)})` : ''
|
|
4287
|
+
return { ...r, text: r.read.renewed ? `The session has a new read token for ${name}${until}: every folder reads its square with it.` : `The session's read token for ${name} works${until}; nothing to renew.` }
|
|
4288
|
+
}
|
|
4289
|
+
const text = r.shadow ? `This session now reads the quilt ${r.shadow.slug || r.shadow.id}'s log (quilt_history); git stays its source of truth until the cut-over.` : r.was ? `This session no longer reads the quilt ${r.was.slug || r.was.id}.` : 'This session was not linked to a quilt.'
|
|
4290
|
+
return { ...r, text }
|
|
4291
|
+
}
|
|
4292
|
+
|
|
4293
|
+
/**
|
|
4294
|
+
* Back to the branches (layout 2) from the kept branch documents, as the owner with `token`.
|
|
4295
|
+
* The relay refuses while the quilt has open squares, unless each is named in `carried` (its
|
|
4296
|
+
* work carried over by hand: seam diff <square> | git apply).
|
|
4297
|
+
*/
|
|
4298
|
+
async quiltUnswitch (token, carried = []) {
|
|
4299
|
+
if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay')
|
|
4300
|
+
if (!token) throw new Error('sign in first (quilt login, or set SEAM_TOKEN): going back reads the quilt\'s open squares as its owner')
|
|
4301
|
+
const r = await this.conn.branchRequest({ op: 'quilt-unswitch', token, carried }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS })
|
|
4302
|
+
const lines = [r.shadow
|
|
4303
|
+
? `The session works from its branches and git again; it still reads the quilt ${r.shadow.slug || r.shadow.id}'s log.`
|
|
4304
|
+
: 'The session works from its branches again; the squares keep their live documents.']
|
|
4305
|
+
const quilt = (r.quilt && (r.quilt.slug || r.quilt.id)) || ''
|
|
4306
|
+
const list = (a) => `${a.slice(0, 8).join(', ')}${a.length > 8 ? `, and ${a.length - 8} more` : ''}`
|
|
4307
|
+
for (const k of r.kept || []) {
|
|
4308
|
+
const sq = squareShort(k.square)
|
|
4309
|
+
if (k.error) { lines.push(`No folder follows square ${sq} (${k.branch}) now, and the relay could not copy its work back (${k.error}): it is in the square (seam diff ${sq} --quilt ${quilt}) and on the relay.`); continue }
|
|
4310
|
+
if (k.copied.length) lines.push(`No folder follows square ${sq} (${k.branch}) now, so the relay copied its work into ${k.branch}: ${list(k.copied)}.`)
|
|
4311
|
+
if (k.uncommitted) lines.push(`Its live work could not be committed in Seam (${k.uncommitted}); what was copied is in ${k.branch}.`)
|
|
4312
|
+
else if (k.commit) lines.push(`All of square ${sq}'s work stays readable in Seam at ${String(k.commit).slice(0, 12)} (seam diff ${sq} --quilt ${quilt}).`)
|
|
4313
|
+
for (const x of k.left) lines.push(`Not copied into ${k.branch}: ${x.path}, ${x.why}${k.commit && !k.uncommitted ? `; get it with seam cat ${String(k.commit).slice(0, 12)} ${x.path} --quilt ${quilt}` : ''}.`)
|
|
4314
|
+
}
|
|
4315
|
+
return { ...r, text: lines.join('\n') }
|
|
4316
|
+
}
|
|
4317
|
+
|
|
3689
4318
|
async syncBranchNow () {
|
|
3690
4319
|
// A folder that can't bring commits in itself (no git here, or its fetch fails) asks the
|
|
3691
4320
|
// relay to look at GitHub for its branch now (relay-upstream.js), as a hosted agent can.
|
|
@@ -4397,8 +5026,9 @@ export class Session extends EventEmitter {
|
|
|
4397
5026
|
* Pushes the on-disk state of a path into the shared doc. Returns true if anything changed.
|
|
4398
5027
|
* `pulled`: git brought this version (a pull), so it is not an edit of ours: never claimed, and counted as the pull's.
|
|
4399
5028
|
*/
|
|
4400
|
-
|
|
4401
|
-
|
|
5029
|
+
/** `claim`: false keeps this edit from claiming (boxing) the file for us (leaveSquare: another member's work). */
|
|
5030
|
+
ingest (rel, { pulled = false, same = false, claim: mayClaim = true } = {}) {
|
|
5031
|
+
if (!this.syncable(rel) || this.inSquare || this.syncStopped) return false
|
|
4402
5032
|
if (this.merging.has(rel)) return false // its offline merge hasn't run yet; see mergeOffline
|
|
4403
5033
|
if (this.held()) { this.heldPaths.add(rel); return false } // git is at work in this folder; see onSettled
|
|
4404
5034
|
if (this.pullWait.has(rel)) { if (!this.onDisk(rel)) return false; this.endPullWait(rel) } // made way for a pull: not a deletion
|
|
@@ -4407,7 +5037,7 @@ export class Session extends EventEmitter {
|
|
|
4407
5037
|
if (this.downloading.has(rel)) return false // our copy is being replaced by a download
|
|
4408
5038
|
if (this.writeFailed.has(rel)) return false // the shared version never reached the disk: what's there is no edit of ours
|
|
4409
5039
|
// New ignore rules: a box of ours on a file they now leave out lets go (settleBoxesOnce).
|
|
4410
|
-
if (IGNORE_FILES.includes(path.posix.basename(rel))) { this.ig = loadIgnore(this.root); this.settleBoxesSoon() }
|
|
5040
|
+
if (IGNORE_FILES.includes(path.posix.basename(rel)) || rel === '.gitmodules') { this.ig = loadIgnore(this.root); this.settleBoxesSoon() }
|
|
4411
5041
|
const disk = this.readDisk(rel)
|
|
4412
5042
|
const boxed = this.boxedByOther(rel)
|
|
4413
5043
|
if (boxed) {
|
|
@@ -4472,7 +5102,7 @@ export class Session extends EventEmitter {
|
|
|
4472
5102
|
const quiltOwn = this.quiltOwnWrite === rel && disk && disk.key === this.quiltOwnKey
|
|
4473
5103
|
if (quiltOwn) { this.quiltOwnWrite = null; this.quiltOwnKey = null }
|
|
4474
5104
|
// With boxes, a deletion is boxed too: it reaches nobody else's folder until it's committed.
|
|
4475
|
-
if ((disk || this.boxesOn()) && !pulled && !quiltOwn && this.ready && !this.seeding && changesIt && (!claim || this.autoClaims.has(rel) || this.unboxedOwn(rel, claim)) && this.aiMayBeEditing()) this.autoClaim(rel)
|
|
5105
|
+
if (mayClaim && (disk || this.boxesOn()) && !pulled && !quiltOwn && this.ready && !this.seeding && changesIt && (!claim || this.autoClaims.has(rel) || this.unboxedOwn(rel, claim)) && this.aiMayBeEditing()) this.autoClaim(rel)
|
|
4476
5106
|
if (!pulled && this.ownBox(rel)) this.settleBoxesSoon() // put back to what was committed, say: the box opens
|
|
4477
5107
|
|
|
4478
5108
|
if (!disk) {
|
|
@@ -4552,7 +5182,8 @@ export class Session extends EventEmitter {
|
|
|
4552
5182
|
texts = { before, after: disk.text }
|
|
4553
5183
|
// Folders on one branch pull the same commits, often at the same moment: made under an id
|
|
4554
5184
|
// taken from the change, the pull's edit is the same edit from each of them, and lands once.
|
|
4555
|
-
|
|
5185
|
+
// So is the square's work every folder brings back when a session leaves layout 3 (leaveSquare).
|
|
5186
|
+
detail = pulled || same ? asClient(this.bdoc, sameEditClient(pulled ? 'pull' : 'square', rel, this.files.has(rel) ? before : null, disk.text), put) : put()
|
|
4556
5187
|
}
|
|
4557
5188
|
if (disk.exec) { if (this.modes.get(rel) !== 'x') this.modes.set(rel, 'x') } else if (this.modes.has(rel)) this.modes.delete(rel)
|
|
4558
5189
|
this.notePresentHere(rel, this.entryBase())
|
|
@@ -4646,13 +5277,18 @@ export class Session extends EventEmitter {
|
|
|
4646
5277
|
* `texts` is { before, after } for text files, so the chronology keeps the diff. `pulled`: a git pull
|
|
4647
5278
|
* brought it: counted as the pull's, and left out of the activity log (notePull writes one line for it all).
|
|
4648
5279
|
*/
|
|
4649
|
-
recordActivity (rel, kind, detail, texts, { pulled = false } = {}) {
|
|
5280
|
+
recordActivity (rel, kind, detail, texts, { pulled = false, activity = true } = {}) {
|
|
4650
5281
|
const now = Date.now()
|
|
4651
5282
|
this.tally(rel, kind, detail, now, texts, pulled)
|
|
4652
5283
|
// A folder's starting copy of a branch (seeding) is nobody's change and no task's: it is on record, but never blamed or counted as unpublished work.
|
|
4653
5284
|
this.history.record({ by: this.name, path: rel, kind, detail, before: texts?.before, after: texts?.after, task: pulled || this.seeding ? null : this.currentTask(), pulled, seed: !!this.seeding, ts: now })
|
|
4654
5285
|
// A pull is logged once by notePull; a starting copy (seeding) is nobody's edit, so the file tree names no one for it.
|
|
4655
|
-
if (pulled || this.seeding) return
|
|
5286
|
+
if (pulled || this.seeding || !activity) return
|
|
5287
|
+
this.pushActivity(rel, kind, detail, now)
|
|
5288
|
+
}
|
|
5289
|
+
|
|
5290
|
+
/** The room's activity log: one entry for a change of ours (bursts to one file fold into one). */
|
|
5291
|
+
pushActivity (rel, kind, detail, now = Date.now()) {
|
|
4656
5292
|
const last = this.lastActivityPush.get(rel)
|
|
4657
5293
|
// Collapse bursts of edits to the same file into one entry.
|
|
4658
5294
|
if (kind === 'edited' && last && now - last < 20000) return
|
|
@@ -4741,6 +5377,7 @@ export class Session extends EventEmitter {
|
|
|
4741
5377
|
* the others: it's noted in writeFailed and tried again later.
|
|
4742
5378
|
*/
|
|
4743
5379
|
applyRemote (paths) {
|
|
5380
|
+
if (this.inSquare || this.syncStopped) return
|
|
4744
5381
|
const failed = []
|
|
4745
5382
|
for (const p of paths) {
|
|
4746
5383
|
try { this.fromRemote(p); this.writeFailed.delete(p) } catch { failed.push(p) }
|
|
@@ -4787,7 +5424,7 @@ export class Session extends EventEmitter {
|
|
|
4787
5424
|
}
|
|
4788
5425
|
|
|
4789
5426
|
writeOut (rel) {
|
|
4790
|
-
if (!this.syncable(rel)) return
|
|
5427
|
+
if (!this.syncable(rel) || this.inSquare || this.syncStopped) return
|
|
4791
5428
|
if (this.ghost(rel)) return
|
|
4792
5429
|
if (this.merging.has(rel)) return // mergeOffline writes this path once it has merged it
|
|
4793
5430
|
if (this.held()) { this.heldPaths.add(rel); return } // written back when the hold ends (writeBack)
|
|
@@ -4833,7 +5470,7 @@ export class Session extends EventEmitter {
|
|
|
4833
5470
|
this.lastKnown.set(rel, shared)
|
|
4834
5471
|
}
|
|
4835
5472
|
// New ignore rules: a box of ours on a file they now leave out lets go (settleBoxesOnce).
|
|
4836
|
-
if (IGNORE_FILES.includes(path.posix.basename(rel))) { this.ig = loadIgnore(this.root); this.settleBoxesSoon() }
|
|
5473
|
+
if (IGNORE_FILES.includes(path.posix.basename(rel)) || rel === '.gitmodules') { this.ig = loadIgnore(this.root); this.settleBoxesSoon() }
|
|
4837
5474
|
|
|
4838
5475
|
if (this.ready && !this.arriving) { // a move writing its branch onto the folder is no partner's change
|
|
4839
5476
|
this.emit('file-changed', { path: rel, by: this.lastEditorOf(rel) || 'partner' })
|
|
@@ -4985,7 +5622,7 @@ export class Session extends EventEmitter {
|
|
|
4985
5622
|
|
|
4986
5623
|
/** Tries failed uploads, downloads and writes again. */
|
|
4987
5624
|
retryFailed () {
|
|
4988
|
-
if (!this.ready || this.stopped) return
|
|
5625
|
+
if (!this.ready || this.stopped || this.inSquare || this.syncStopped) return
|
|
4989
5626
|
if (this.hold && this.hold.kind === 'switching' && !this.following) this.recheckSoon()
|
|
4990
5627
|
const failed = [...this.retry]
|
|
4991
5628
|
this.retry.clear()
|
|
@@ -5294,6 +5931,7 @@ export class Session extends EventEmitter {
|
|
|
5294
5931
|
|
|
5295
5932
|
/** Stats the folder and queues every path that appeared, changed or vanished since the last scan. */
|
|
5296
5933
|
scanDisk ({ baseline = false } = {}) {
|
|
5934
|
+
if (this.inSquare || this.syncStopped) return
|
|
5297
5935
|
const seen = new Set()
|
|
5298
5936
|
for (const rel of walk(this.root, this.ig)) {
|
|
5299
5937
|
let st
|
|
@@ -5339,7 +5977,7 @@ export class Session extends EventEmitter {
|
|
|
5339
5977
|
|
|
5340
5978
|
/**
|
|
5341
5979
|
* Joins, updates or leaves the session's voice call (src/voice.js): `v` is null to leave, or
|
|
5342
|
-
* { muted, sig, transcribing } from the app window, which does the audio. Only people talk.
|
|
5980
|
+
* { muted, sig, transcribing, sharing } from the app window, which does the audio. Only people talk.
|
|
5343
5981
|
*/
|
|
5344
5982
|
setVoice (v) {
|
|
5345
5983
|
if (!this.conn || !this.conn.awareness) throw new Error('Not connected to the session yet.')
|
|
@@ -5524,6 +6162,8 @@ export class Session extends EventEmitter {
|
|
|
5524
6162
|
requestCommit (message, { files = null, task = null, via = null, by = null, id = null, description = '', quiet = false } = {}) {
|
|
5525
6163
|
message = String(message || '').trim().slice(0, MAX_MESSAGE)
|
|
5526
6164
|
if (this.access && this.access.state === 'approved' && this.access.role === 'viewer') throw new Error('viewers can’t ask for commits')
|
|
6165
|
+
// In Seam nobody waits on someone with git: whoever is on a square commits it.
|
|
6166
|
+
if (this.quilt && this.quilt.layout === 3) throw new Error('This session\'s code is committed in Seam, so there is no one to ask: commit your square yourself (the square button at the top of the app, quilt commit "<message>", or seam_commit for an AI).')
|
|
5527
6167
|
// A commit request is a message to the host: the relay undoes it from someone who may not post.
|
|
5528
6168
|
if (!this.mayTalk()) throw new Error(TALK_REFUSED)
|
|
5529
6169
|
const t = task ? this.taskList().find((x) => x.id === String(task)) : null
|
|
@@ -5634,9 +6274,91 @@ export class Session extends EventEmitter {
|
|
|
5634
6274
|
historyQuery ({ path, by, since, task, limit } = {}) {
|
|
5635
6275
|
const from = parseSince(since)
|
|
5636
6276
|
if (from === undefined) throw new Error('since: use a duration like 2h or 3d, "today", "yesterday", or a date')
|
|
5637
|
-
|
|
6277
|
+
// Once the session is a quilt (the relay says so, src/server.js branchesFields), older changes are its legacy tail.
|
|
6278
|
+
const entries = legacyTail(this.history.entries(), { at: this.quilt && this.quilt.at, history: !!(this.quilt && this.quilt.history) })
|
|
6279
|
+
return queryHistory(entries, { path, by, since: from ?? undefined, task, limit: Math.min(Number(limit) || 50, 500) })
|
|
6280
|
+
}
|
|
6281
|
+
|
|
6282
|
+
/**
|
|
6283
|
+
* quilt_history: historyQuery, and in a session linked to a quilt (Seam on, the relay says the
|
|
6284
|
+
* session is a quilt or shadows one, 16) the quilt's log merged in (quilt-history.js): read as
|
|
6285
|
+
* this member (seamMember: SEAM_TOKEN, else the session's Quilt sign-in, the same member as the
|
|
6286
|
+
* Seam tools), else through the relay with the room's read token.
|
|
6287
|
+
* { entries, note? }; the diffs of the log's commits only when `with_diff`.
|
|
6288
|
+
*/
|
|
6289
|
+
async historyLinked ({ path: p, by, since, task, limit, with_diff: withDiff } = {}) {
|
|
6290
|
+
// The relay says whether the room is linked (it has Seam on; this app may not): its read token answers then.
|
|
6291
|
+
const link = this.quiltLink()
|
|
6292
|
+
if (!link) return { entries: this.historyQuery({ path: p, by, since, task, limit }) }
|
|
6293
|
+
const from = parseSince(since)
|
|
6294
|
+
if (from === undefined) throw new Error('since: use a duration like 2h or 3d, "today", "yesterday", or a date')
|
|
6295
|
+
const own = legacyTail(this.history.entries(), { at: this.quilt && this.quilt.at, history: !!(this.quilt && this.quilt.history) })
|
|
6296
|
+
const query = { path: p || '', by, since: from ?? undefined, task, limit: Math.min(Number(limit) || 50, 500), withDiff: !!withDiff }
|
|
6297
|
+
const quilt = { id: link.id, slug: link.slug || '' }
|
|
6298
|
+
const api = this.seamApiUrl ?? appSeamUrl()
|
|
6299
|
+
// As this member; one Seam would not let in (an agent with no member row, say) is not asked again for a
|
|
6300
|
+
// minute. No sign-in yet is not Seam's answer: a sign-in made meanwhile (quilt login, SEAM_TOKEN) is used at once.
|
|
6301
|
+
let seam = null
|
|
6302
|
+
if (api && !(this.historySeamOff && Date.now() - this.historySeamOff < HISTORY_SEAM_OFF_MS)) {
|
|
6303
|
+
try { seam = (await this.seamMember().client(quilt.id, [], { read: true, retry: false })).client } catch (err) { if (err.status) this.historySeamOff = Date.now() }
|
|
6304
|
+
}
|
|
6305
|
+
if (seam) {
|
|
6306
|
+
// No retries: Seam down falls back to the relay's read at once.
|
|
6307
|
+
const r = await linkedHistory({ seam, quilt, entries: own, query })
|
|
6308
|
+
if (!r.note) return { entries: r.list }
|
|
6309
|
+
}
|
|
6310
|
+
let got = null
|
|
6311
|
+
try { got = this.conn && this.conn.connected ? await this.conn.branchRequest({ op: 'quilt-log', query: { ...query, with_diff: !!withDiff } }) : null } catch (err) { got = { note: `Seam's log for ${quilt.slug || quilt.id} could not be read (${err.message}); this is the session's own record.` } }
|
|
6312
|
+
const note = !got ? `Seam's log for ${quilt.slug || quilt.id} was not read: this folder is not connected to the relay.` : got.note || ''
|
|
6313
|
+
return { entries: mergeLinked(own, (got && got.entries) || [], { ...query, logRead: !note }), ...(note ? { note } : {}) }
|
|
6314
|
+
}
|
|
6315
|
+
|
|
6316
|
+
/** The quilt the relay says this session is, or shadows (16 step 1): { id, slug, shadow, at? }, or null. */
|
|
6317
|
+
quiltLink () {
|
|
6318
|
+
const l = this.quilt || this.quiltShadow
|
|
6319
|
+
// `layout: 3`: the session's files are in its squares (the app's source control is Seam's then).
|
|
6320
|
+
return l && l.id ? { id: l.id, slug: l.slug || '', shadow: !this.quilt, ...(this.quilt && this.quilt.at ? { at: this.quilt.at } : {}), ...(this.quilt && this.quilt.layout === 3 ? { layout: 3 } : {}) } : null
|
|
6321
|
+
}
|
|
6322
|
+
|
|
6323
|
+
/**
|
|
6324
|
+
* A file's History in the app, in a session linked to a quilt: its live changes not in the
|
|
6325
|
+
* quilt's log yet, then the patches that touched it (each newest first), the same chronology
|
|
6326
|
+
* quilt_history gives agents. null when the session is not linked.
|
|
6327
|
+
*/
|
|
6328
|
+
async fileHistory (file) {
|
|
6329
|
+
const quilt = this.quiltLink()
|
|
6330
|
+
if (!quilt) return null
|
|
6331
|
+
// The app asks again after every save to the file: the log is read at most every 30 s a file.
|
|
6332
|
+
const logs = this.fileLogs || (this.fileLogs = new Map())
|
|
6333
|
+
let got = logs.get(file)
|
|
6334
|
+
if (!got || got.quilt !== quilt.id || Date.now() - got.at > FILE_LOG_MS) {
|
|
6335
|
+
const { entries, note } = await this.historyLinked({ path: file, limit: 100 })
|
|
6336
|
+
got = { quilt: quilt.id, at: Date.now(), patches: entries.filter((e) => e.seam).reverse(), note: note || '' }
|
|
6337
|
+
logs.delete(file)
|
|
6338
|
+
logs.set(file, got)
|
|
6339
|
+
if (logs.size > 50) logs.delete(logs.keys().next().value)
|
|
6340
|
+
}
|
|
6341
|
+
const newest = got.patches.length ? got.patches[0].ts : 0
|
|
6342
|
+
// What git brought in is the log's, once the log was read; the session's starting copy (seed) is nobody's live work.
|
|
6343
|
+
const live = this.history.entries().filter((e) => e.path === file && e.ts > newest && !e.seed && !(e.pulled && !got.note)).reverse()
|
|
6344
|
+
return { quilt, live, patches: got.patches, ...(got.note ? { note: got.note } : {}) }
|
|
6345
|
+
}
|
|
6346
|
+
|
|
6347
|
+
/** A read of the linked quilt through the relay, with the room's read token: { quilt: null } when the session is not linked. */
|
|
6348
|
+
async quiltRead (req) {
|
|
6349
|
+
if (!this.quiltLink()) return { quilt: null }
|
|
6350
|
+
if (!this.conn || !this.conn.connected) throw new Error('This folder is not connected to the relay.')
|
|
6351
|
+
const { id, op, ...r } = await this.conn.branchRequest(req)
|
|
6352
|
+
return r
|
|
5638
6353
|
}
|
|
5639
6354
|
|
|
6355
|
+
/** Who wrote each line of a file on main (Seam's blame); { working: true } while Seam works it out. */
|
|
6356
|
+
quiltBlame (file) { return this.quiltRead({ op: 'quilt-blame', path: file }) }
|
|
6357
|
+
/** Code search over main. */
|
|
6358
|
+
quiltSearch (q) { return this.quiltRead({ op: 'quilt-search', q }) }
|
|
6359
|
+
/** One patch of the log: its files and the diff of `file`. */
|
|
6360
|
+
quiltShow (commit, file) { return this.quiltRead({ op: 'quilt-show', commit, path: file }) }
|
|
6361
|
+
|
|
5640
6362
|
/** `input` is a title, or { title, assignee, forAi, to_ai, tool, files }. "me" means this person. */
|
|
5641
6363
|
addTask (input) {
|
|
5642
6364
|
const fields = typeof input === 'string' ? { title: input } : { ...(input || {}) }
|
|
@@ -6038,7 +6760,20 @@ export class Session extends EventEmitter {
|
|
|
6038
6760
|
* agents may push here. The agent then works and pushes there like anyone; the pull request opens
|
|
6039
6761
|
* by itself. Returns the branch, or null when nothing was done (people's folders, no git, already on its own branch).
|
|
6040
6762
|
*/
|
|
6041
|
-
|
|
6763
|
+
branchForTask (task) { return this.branchMove(() => this.branchForTaskNow(task)) }
|
|
6764
|
+
|
|
6765
|
+
/**
|
|
6766
|
+
* One branch move at a time in this agent's folder: a task's new branch (branchForTask) and leaving
|
|
6767
|
+
* a merged one (leaveMergedBranch) both check out; at once, they fought over git's index, or the
|
|
6768
|
+
* second undid the first (an agent taking a task as its last one's branch merged got no branch).
|
|
6769
|
+
*/
|
|
6770
|
+
branchMove (fn) {
|
|
6771
|
+
const p = (this.branchMoves || Promise.resolve()).then(fn)
|
|
6772
|
+
this.branchMoves = p.catch(() => {})
|
|
6773
|
+
return p
|
|
6774
|
+
}
|
|
6775
|
+
|
|
6776
|
+
async branchForTaskNow (task) {
|
|
6042
6777
|
if (this.kind !== 'agent' || !this.git || !this.git.branch || !task || !task.title || !this.canCommitHere()) return null
|
|
6043
6778
|
const prefix = agentPrefix(this.name)
|
|
6044
6779
|
const settle = async () => { for (let i = 0; i < 20 && (this.hold || gitBusy(this.root, gitDir(this.root))); i++) await new Promise((r) => setTimeout(r, 250)) }
|
|
@@ -6089,7 +6824,9 @@ export class Session extends EventEmitter {
|
|
|
6089
6824
|
* its own the merge didn't take (changes not committed, or commits the session's record of the branch
|
|
6090
6825
|
* hasn't), the folder checks out the branch it merged into and brings it up to date. Returns the branch, or null.
|
|
6091
6826
|
*/
|
|
6092
|
-
|
|
6827
|
+
leaveMergedBranch (b) { return this.branchMove(() => this.leaveMergedBranchNow(b)) }
|
|
6828
|
+
|
|
6829
|
+
async leaveMergedBranchNow (b) {
|
|
6093
6830
|
const here = b.key
|
|
6094
6831
|
const into = this.branchList.some((x) => x.key === b.merged.into) ? b.merged.into : (this.branchList.find((x) => x.default) || {}).key || 'main'
|
|
6095
6832
|
if (!into || into === here) return null
|
|
@@ -6252,14 +6989,32 @@ export class Session extends EventEmitter {
|
|
|
6252
6989
|
if (!dirty) return null
|
|
6253
6990
|
const rels = [...dirty.keys()].filter((rel) => this.syncable(rel))
|
|
6254
6991
|
const history = this.history.entries()
|
|
6255
|
-
// Each changed line is put down to whoever added or removed it last; a file git or this folder
|
|
6256
|
-
// can't give as text falls back to who edited it since it was last committed.
|
|
6257
|
-
const before = await filesAt(this.root, 'HEAD', rels.filter((rel) => dirty.get(rel) !== 'A')) || new Map()
|
|
6258
|
-
const editors = new Map()
|
|
6259
6992
|
const byPath = new Map()
|
|
6260
6993
|
for (const h of history) { if (h && h.path) { if (!byPath.has(h.path)) byPath.set(h.path, []); byPath.get(h.path).push(h) } }
|
|
6261
|
-
|
|
6994
|
+
// Who changed a file is worked out again only when the file, its history or HEAD changed since the
|
|
6995
|
+
// last look. Done for every file every two minutes (reviewCommits), reading each one and its copy
|
|
6996
|
+
// at HEAD and diffing them, it held hundreds of MB at a time in a folder far from its HEAD.
|
|
6997
|
+
const head = ((await this.gitOut(['rev-parse', '--verify', '-q', 'HEAD'])) || '').trim()
|
|
6998
|
+
const keyOf = (rel) => {
|
|
6999
|
+
let st = null
|
|
7000
|
+
try { st = fs.statSync(path.join(this.root, rel)) } catch {}
|
|
7001
|
+
const h = byPath.get(rel) || []
|
|
7002
|
+
return `${head} ${dirty.get(rel)} ${st ? `${st.size} ${st.mtimeMs}` : '-'} ${h.length} ${h.length ? h[h.length - 1].ts : 0}`
|
|
7003
|
+
}
|
|
7004
|
+
const cache = (this.editorsCache ||= new Map())
|
|
7005
|
+
const editors = new Map()
|
|
7006
|
+
const todo = new Map() // rel -> its key
|
|
6262
7007
|
for (const rel of rels) {
|
|
7008
|
+
const key = keyOf(rel)
|
|
7009
|
+
const c = cache.get(rel)
|
|
7010
|
+
if (c && c.key === key) editors.set(rel, c.names)
|
|
7011
|
+
else todo.set(rel, key)
|
|
7012
|
+
}
|
|
7013
|
+
// Each changed line is put down to whoever added or removed it last; a file git or this folder
|
|
7014
|
+
// can't give as text falls back to who edited it since it was last committed.
|
|
7015
|
+
const before = todo.size ? await filesAt(this.root, 'HEAD', [...todo.keys()].filter((rel) => dirty.get(rel) !== 'A')) || new Map() : new Map()
|
|
7016
|
+
const rest = []
|
|
7017
|
+
for (const rel of todo.keys()) {
|
|
6263
7018
|
const was = dirty.get(rel) === 'A' ? '' : before.get(rel)
|
|
6264
7019
|
let now = ''
|
|
6265
7020
|
if (dirty.get(rel) !== 'D') { try { now = fs.readFileSync(path.join(this.root, rel), 'utf8') } catch { now = null } }
|
|
@@ -6267,6 +7022,8 @@ export class Session extends EventEmitter {
|
|
|
6267
7022
|
editors.set(rel, blameChange({ before: was, after: now, entries: byPath.get(rel) || [] }))
|
|
6268
7023
|
}
|
|
6269
7024
|
if (rest.length) for (const [rel, names] of editorsSince(rest, history, await lastCommitted(this.root))) editors.set(rel, names)
|
|
7025
|
+
for (const [rel, key] of todo) cache.set(rel, { key, names: editors.get(rel) })
|
|
7026
|
+
for (const rel of cache.keys()) if (!dirty.has(rel)) cache.delete(rel)
|
|
6270
7027
|
const st = this.commitStatus({ includeMe: false })
|
|
6271
7028
|
const busy = st.busy.map((b) => b.name)
|
|
6272
7029
|
const claims = [...this.claims.values()]
|
|
@@ -6404,8 +7161,9 @@ export class Session extends EventEmitter {
|
|
|
6404
7161
|
if (!client) return
|
|
6405
7162
|
this.agents.add(String(client).slice(0, 80))
|
|
6406
7163
|
this.conn.awareness.setLocalStateField('agents', [...this.agents])
|
|
6407
|
-
|
|
6408
|
-
|
|
7164
|
+
// The AI that connects is the tool this person uses, whatever Settings says (toolInUse).
|
|
7165
|
+
const used = toolInUse(this.tool, this.agents)
|
|
7166
|
+
if (used !== this.tool) {
|
|
6409
7167
|
this.tool = used
|
|
6410
7168
|
this.conn.awareness.setLocalStateField('tool', used)
|
|
6411
7169
|
}
|
|
@@ -7094,7 +7852,7 @@ export class Session extends EventEmitter {
|
|
|
7094
7852
|
|
|
7095
7853
|
/** settleBoxes in a moment: after a commit, a file put back, or an edit to one of our boxed files. */
|
|
7096
7854
|
settleBoxesSoon (ms = SETTLE_BOXES_MS) {
|
|
7097
|
-
if (this.stopped || !this.git || this.settleBoxesTimer) return
|
|
7855
|
+
if (this.stopped || !this.git || this.settleBoxesTimer || this.inSquare || this.syncStopped) return
|
|
7098
7856
|
this.settleBoxesTimer = setTimeout(() => {
|
|
7099
7857
|
this.settleBoxesTimer = null
|
|
7100
7858
|
this.settleBoxes().catch((err) => this.log(`could not look at your boxes: ${err.message}`))
|
|
@@ -7458,6 +8216,7 @@ export class Session extends EventEmitter {
|
|
|
7458
8216
|
* claim that covers a path that isn't a claim itself }.
|
|
7459
8217
|
*/
|
|
7460
8218
|
async releaseReport (pattern = '*', via = null) {
|
|
8219
|
+
if (this.quilt && this.quilt.id && this.quilt.layout === 3) return this.releaseInSeam(String(pattern || '*'), via)
|
|
7461
8220
|
const r = await this.conn.claimRequest({ op: 'release', pattern: String(pattern), ...this.as(via) })
|
|
7462
8221
|
if (r.released) this.noteActivity(`Released ${pattern === '*' ? 'all my claims' : pattern}`, { via })
|
|
7463
8222
|
const out = { released: r.released || 0 }
|
|
@@ -7465,6 +8224,27 @@ export class Session extends EventEmitter {
|
|
|
7465
8224
|
return out
|
|
7466
8225
|
}
|
|
7467
8226
|
|
|
8227
|
+
/**
|
|
8228
|
+
* releaseReport at layout 3, where claims are Seam's (seam-claims.js): this member lets go of theirs
|
|
8229
|
+
* in Seam as themselves (seamMember), the claims the move made for them included, though the mover
|
|
8230
|
+
* holds those in Seam (the relay says which: quilt-claims). One someone waits for is kept (`held`):
|
|
8231
|
+
* Seam hands it on with a note instead.
|
|
8232
|
+
*/
|
|
8233
|
+
async releaseInSeam (pattern = '*', via = null) {
|
|
8234
|
+
if (!this.conn) throw new Error('not connected to the relay')
|
|
8235
|
+
let asked
|
|
8236
|
+
try { asked = await this.conn.claimRequest({ op: 'quilt-claims', ...this.as(via) }) } catch (err) {
|
|
8237
|
+
// A relay from before 0.3.132 can't say whose the move's claims were: let go of nothing rather than someone else's.
|
|
8238
|
+
throw new Error(`The relay could not say whose this session's claims are (${err.message}), so none was let go of. Try again once the relay is updated, or let go of one in Seam (seam unclaim <claim> --quilt ${this.quilt.slug || this.quilt.id}).`)
|
|
8239
|
+
}
|
|
8240
|
+
const { gone, ...r } = await releaseInSeam({ access: this.seamMember(), api: this.seamApiUrl ?? appSeamUrl(), quilt: this.quilt.id, asked, pattern })
|
|
8241
|
+
if (gone.length) {
|
|
8242
|
+
await this.conn.claimRequest({ op: 'quilt-released', ids: gone, ...this.as(via) }).catch(() => {})
|
|
8243
|
+
this.noteActivity(`Released ${pattern === '*' ? 'all my claims' : pattern}`, { via })
|
|
8244
|
+
}
|
|
8245
|
+
return r
|
|
8246
|
+
}
|
|
8247
|
+
|
|
7468
8248
|
/**
|
|
7469
8249
|
* The session's boxes as the app shows them (boxes.js): each with what it changes from the version
|
|
7470
8250
|
* this folder has. Someone else's: from this folder's version before the box (what is on disk);
|
|
@@ -7883,9 +8663,12 @@ export class Session extends EventEmitter {
|
|
|
7883
8663
|
const fromGithub = (p) => { const v = gh && gh.get(p); return v && typeof v.sha === 'string' ? { sha: v.sha, by: v.by || '', at: v.at || 0, came: v.came || 0, subject: v.subject || '' } : null }
|
|
7884
8664
|
const out = []
|
|
7885
8665
|
// A diff branch's files the session never changed are the folder's own checkout's (branch-view.js): listed too.
|
|
7886
|
-
|
|
8666
|
+
// Following a square in place (layout 3), the folder is the square's live copy: listed from disk.
|
|
8667
|
+
const square = this.squareFiles()
|
|
8668
|
+
const listed = square ? new Set(square.keys()) : this.diffForm ? new Set([...this.sharedPaths(), ...[...this.gitOnly.keys()].filter((p) => this.followsGit(p) && this.onDisk(p))]) : this.sharedPaths()
|
|
7887
8669
|
for (const p of listed) {
|
|
7888
8670
|
if (!isSafeRelPath(p)) continue
|
|
8671
|
+
if (square) { out.push({ path: p, binary: square.get(p), edited: edited.get(p) || null, claim: claimFor(p), github: null }); continue }
|
|
7889
8672
|
const blob = this.blobs.get(p)
|
|
7890
8673
|
out.push({ path: p, binary: blob ? !this.files.has(p) : this.diffForm && this.files.has(p) === false && /^bin:/.test(String(this.gitOnly.get(p) || '')), edited: edited.get(p) || null, claim: claimFor(p), github: fromGithub(p) })
|
|
7891
8674
|
}
|
|
@@ -7910,7 +8693,46 @@ export class Session extends EventEmitter {
|
|
|
7910
8693
|
return out
|
|
7911
8694
|
}
|
|
7912
8695
|
|
|
7913
|
-
/**
|
|
8696
|
+
/**
|
|
8697
|
+
* While the folder follows its square in place (layout 3, session-follow.js), the folder IS the
|
|
8698
|
+
* square's live copy: every edit of everyone on the square comes to disk, and the branch document
|
|
8699
|
+
* the session synced with is frozen, read-only, for a move back. So the app's Files and file views
|
|
8700
|
+
* read the folder then: path -> whether it is binary (sniffed once per size and time), or null when
|
|
8701
|
+
* the folder does not follow a square.
|
|
8702
|
+
*/
|
|
8703
|
+
squareFiles () {
|
|
8704
|
+
if (!this.inSquare) return null
|
|
8705
|
+
const seen = this.squareSniff || (this.squareSniff = new Map()) // path -> { key, binary }
|
|
8706
|
+
const out = new Map()
|
|
8707
|
+
for (const rel of walk(this.root, this.ig)) {
|
|
8708
|
+
if (!this.syncable(rel)) continue
|
|
8709
|
+
let st
|
|
8710
|
+
try { st = fs.statSync(path.join(this.root, rel)) } catch { continue }
|
|
8711
|
+
const key = `${st.size}:${st.mtimeMs}`
|
|
8712
|
+
let c = seen.get(rel)
|
|
8713
|
+
if (!c || c.key !== key) {
|
|
8714
|
+
let binary = st.size > MAX_TEXT_BYTES
|
|
8715
|
+
if (!binary) { try { binary = looksBinary(fs.readFileSync(path.join(this.root, rel))) } catch { continue } }
|
|
8716
|
+
c = { key, binary }
|
|
8717
|
+
seen.set(rel, c)
|
|
8718
|
+
}
|
|
8719
|
+
out.set(rel, c.binary)
|
|
8720
|
+
}
|
|
8721
|
+
for (const rel of seen.keys()) if (!out.has(rel)) seen.delete(rel)
|
|
8722
|
+
return out
|
|
8723
|
+
}
|
|
8724
|
+
|
|
8725
|
+
/** One file of the folder while it follows its square (squareFiles): its bytes, or null when the session doesn't sync it or it is not there. */
|
|
8726
|
+
squareFileBytes (rel) {
|
|
8727
|
+
if (!this.syncable(rel)) return null
|
|
8728
|
+
try {
|
|
8729
|
+
const p = path.join(this.root, rel)
|
|
8730
|
+
if (!fs.statSync(p).isFile()) return null
|
|
8731
|
+
return fs.readFileSync(p)
|
|
8732
|
+
} catch { return null }
|
|
8733
|
+
}
|
|
8734
|
+
|
|
8735
|
+
/** Contents of one shared file, straight from the shared doc (never from disk, but for a folder following its square: squareFiles); a chat file from the chat files folder. */
|
|
7914
8736
|
readShared (rel) {
|
|
7915
8737
|
rel = String(rel || '').replace(/^\.\//, '')
|
|
7916
8738
|
if (!isSafeRelPath(rel)) return null
|
|
@@ -7920,6 +8742,11 @@ export class Session extends EventEmitter {
|
|
|
7920
8742
|
const buf = fs.readFileSync(path.join(this.root, rel))
|
|
7921
8743
|
return buf.length <= MAX_TEXT_BYTES && !looksBinary(buf) ? { path: rel, text: buf.toString('utf8'), chat: e.chat } : { path: rel, binary: true, size: buf.length, chat: e.chat }
|
|
7922
8744
|
}
|
|
8745
|
+
if (this.inSquare) {
|
|
8746
|
+
const buf = this.squareFileBytes(rel)
|
|
8747
|
+
if (!buf) return null
|
|
8748
|
+
return buf.length <= MAX_TEXT_BYTES && !looksBinary(buf) ? { path: rel, text: buf.toString('utf8') } : { path: rel, binary: true, size: buf.length }
|
|
8749
|
+
}
|
|
7923
8750
|
const t = this.files.get(rel)
|
|
7924
8751
|
if (t) return { path: rel, text: t.toString() }
|
|
7925
8752
|
const b = this.blobs.get(rel)
|
|
@@ -7939,6 +8766,10 @@ export class Session extends EventEmitter {
|
|
|
7939
8766
|
if (!this.chatFileEntries().some((x) => x.path === rel)) return null
|
|
7940
8767
|
return { path: rel, buf: fs.readFileSync(path.join(this.root, rel)) }
|
|
7941
8768
|
}
|
|
8769
|
+
if (this.inSquare) {
|
|
8770
|
+
const buf = this.squareFileBytes(rel)
|
|
8771
|
+
return buf ? { path: rel, buf } : null
|
|
8772
|
+
}
|
|
7942
8773
|
const t = this.files.get(rel)
|
|
7943
8774
|
if (t) return { path: rel, buf: Buffer.from(t.toString(), 'utf8') }
|
|
7944
8775
|
const b = this.blobs.get(rel)
|
|
@@ -7963,7 +8794,8 @@ export class Session extends EventEmitter {
|
|
|
7963
8794
|
const git = cleanGit(s.git)
|
|
7964
8795
|
peers.push({
|
|
7965
8796
|
name: s.name,
|
|
7966
|
-
tool:
|
|
8797
|
+
// An older app keeps its person's stale tool after their AI connects: show the AI that did.
|
|
8798
|
+
tool: toolInUse(s.tool, s.agents || []),
|
|
7967
8799
|
color: s.color,
|
|
7968
8800
|
kind: s.kind || 'human',
|
|
7969
8801
|
// A person's cloud AI (its relay checked whose): one of that person's AI sessions.
|
|
@@ -7994,15 +8826,33 @@ export class Session extends EventEmitter {
|
|
|
7994
8826
|
const t = typing.get(p.name)
|
|
7995
8827
|
if (t) { p.typing = true; if (t.to) p.typingTo = t.to }
|
|
7996
8828
|
const v = voices.get(p.name)
|
|
7997
|
-
if (v && !p.persona) p.voice = { muted: v.muted, ...(v.transcribing ? { transcribing: true } : {}) }
|
|
8829
|
+
if (v && !p.persona) p.voice = { muted: v.muted, ...(v.transcribing ? { transcribing: true } : {}), ...(v.sharing ? { sharing: true } : {}) }
|
|
7998
8830
|
}
|
|
7999
8831
|
const myVoice = voices.get(this.name)
|
|
8832
|
+
const square = this.squareFollower ? this.squareFollower.status() : null
|
|
8000
8833
|
return {
|
|
8001
8834
|
room: this.room,
|
|
8002
8835
|
server: this.server,
|
|
8003
8836
|
branch: this.branch,
|
|
8004
8837
|
connected: !!(this.conn && this.conn.connected),
|
|
8005
8838
|
...(this.relayProblem ? { problem: this.relayProblem } : {}),
|
|
8839
|
+
// The quilt the session is linked to (the app's History, Blame and search read it through the relay).
|
|
8840
|
+
...(this.quiltLink() ? { quilt: this.quiltLink() } : {}),
|
|
8841
|
+
// At layout 3: the square this folder follows, with its one line (session-follow.js), and a write the relay could not keep.
|
|
8842
|
+
...(square ? { square } : {}),
|
|
8843
|
+
// In Seam: each branch's square, as Seam names it (the app's sidebar shows who is on which).
|
|
8844
|
+
...(this.quilt && this.quilt.layout === 3 ? { squares: Object.fromEntries(Object.entries(this.quilt.live || {}).filter(([, k]) => typeof k === 'string' && k.startsWith('quilt/')).map(([b, k]) => [b, squareShort(k.slice('quilt/'.length))])) } : {}),
|
|
8845
|
+
// The owner is moving the session into Seam or back right now (the relay's `moving`): { kind, since, line }.
|
|
8846
|
+
...(this.moving ? { moving: { ...this.moving, line: movingLine(this.moving) } } : {}),
|
|
8847
|
+
// The forced date (16 flag 9): live sync stopped here (`stopped`), or when it is ({ at, remote }).
|
|
8848
|
+
...(this.force ? { force: this.force } : {}),
|
|
8849
|
+
// "Make this a quilt" on a session with a git remote: Seam importing it ({ slug, repo }), or linked (quiltShadow).
|
|
8850
|
+
...(this.quiltImporting ? { importing: this.quiltImporting } : {}),
|
|
8851
|
+
...(this.quiltShadow ? { shadow: this.quiltShadow } : {}),
|
|
8852
|
+
// The folder's branch has no square in the quilt (openSquareHere; the app's button opens one).
|
|
8853
|
+
...(!square && this.squareless() ? { squareless: this.squareless() } : {}),
|
|
8854
|
+
// Until the folder follows a square, its edits reach nobody: said until it does.
|
|
8855
|
+
...(this.readOnly && this.quilt && this.quilt.layout === 3 && !(square && !square.opening && !square.problem) ? { readOnly: this.readOnly } : {}),
|
|
8006
8856
|
access: this.access,
|
|
8007
8857
|
sessionName: this.sessionName,
|
|
8008
8858
|
members: this.members,
|
|
@@ -8022,7 +8872,7 @@ export class Session extends EventEmitter {
|
|
|
8022
8872
|
color: this.conn?.awareness.getLocalState()?.color,
|
|
8023
8873
|
agent: { ...(this.agentState || { status: 'idle' }), sharing: this.agentSharing, summarized: !!this.summarizer },
|
|
8024
8874
|
work: this.work,
|
|
8025
|
-
...(myVoice && myVoice.me ? { voice: { muted: myVoice.muted, ...(myVoice.transcribing ? { transcribing: true } : {}) } } : {})
|
|
8875
|
+
...(myVoice && myVoice.me ? { voice: { muted: myVoice.muted, ...(myVoice.transcribing ? { transcribing: true } : {}), ...(myVoice.sharing ? { sharing: true } : {}) } } : {})
|
|
8026
8876
|
},
|
|
8027
8877
|
// The session's call: whether the owner has transcripts on.
|
|
8028
8878
|
voice: { transcribe: transcribeSetting(this.doc, this.members).on },
|
|
@@ -8076,7 +8926,9 @@ export class Session extends EventEmitter {
|
|
|
8076
8926
|
for (const t of this.rechecks.values()) clearTimeout(t)
|
|
8077
8927
|
this.rechecks.clear()
|
|
8078
8928
|
clearInterval(this.reconcileTimer)
|
|
8929
|
+
clearInterval(this.readRenewTimer)
|
|
8079
8930
|
clearInterval(this.upstreamPoll)
|
|
8931
|
+
if (this.squareFollower) await this.squareFollower.stop()
|
|
8080
8932
|
clearTimeout(this.clashTimer)
|
|
8081
8933
|
if (this.clashPending) clearTimeout(this.clashPending.timer)
|
|
8082
8934
|
clearTimeout(this.upstreamTimer)
|
|
@@ -8090,6 +8942,7 @@ export class Session extends EventEmitter {
|
|
|
8090
8942
|
clearTimeout(this.typingExpiryTimer)
|
|
8091
8943
|
if (this.conn) this.conn.close()
|
|
8092
8944
|
this.saveState()
|
|
8945
|
+
this.emit('stopped')
|
|
8093
8946
|
await new Promise((r) => setTimeout(r, 100))
|
|
8094
8947
|
}
|
|
8095
8948
|
}
|