heyquilt 0.0.1 → 0.3.90
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/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// The relay's side of `quilt sync` (turn-sync.js): a sandboxed agent's folder synced in one shot,
|
|
2
|
+
// over the hosted MCP (the quilt_sync tool, relay-mcp.js), with the sync state kept here rather
|
|
3
|
+
// than in the folder. Each of the agent's sandboxes (a slot, from its QUILT_AGENT_KEY) has a
|
|
4
|
+
// checkpoint per branch: what its folder held at the end of its last sync, texts included, so the
|
|
5
|
+
// next sync can tell who changed what and merge. The rules are turn-sync-plan.js.
|
|
6
|
+
//
|
|
7
|
+
// Steps, one request each: plan (the folder's manifest in; what to upload, pull, remove and keep
|
|
8
|
+
// aside out), push (contents in; written, deleted or a merge record, decided again against the
|
|
9
|
+
// session as it is now), pull (the session's version of some paths), done (the folder's manifest
|
|
10
|
+
// after writing; the checkpoint saved).
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import zlib from 'node:zlib'
|
|
14
|
+
import crypto from 'node:crypto'
|
|
15
|
+
import * as Y from 'yjs'
|
|
16
|
+
import { applyTextDiff } from './textdiff.js'
|
|
17
|
+
import { isSafeRelPath, LARGE_FILE_BYTES } from './pathrules.js'
|
|
18
|
+
import { openMerge, MAX_RECORD_TEXT } from './merges.js'
|
|
19
|
+
import { decide, planSync, nextCheckpoint, mergeTexts, behind, sessionEntry, SKIP } from './turn-sync-plan.js'
|
|
20
|
+
|
|
21
|
+
export const SLOT = /^[A-Za-z0-9_-]{8,64}$/
|
|
22
|
+
const HASH = /^[0-9a-f]{40}$/
|
|
23
|
+
/** A text pushed by quilt sync: at most this long (as quilt_write_file). */
|
|
24
|
+
export const MAX_SYNC_TEXT = 1024 * 1024
|
|
25
|
+
/** One pull answer carries at most this much file content; the rest is asked for again. */
|
|
26
|
+
export const MAX_PULL_BYTES = 8 * 1024 * 1024
|
|
27
|
+
/** Checkpoints unused this long are forgotten. */
|
|
28
|
+
const KEEP_MS = 90 * 24 * 60 * 60 * 1000
|
|
29
|
+
export const ROOM_KEY = '∅room' // a room without branch documents
|
|
30
|
+
|
|
31
|
+
const sha1 = (x) => crypto.createHash('sha1').update(x).digest('hex')
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Checkpoints, per room, agent and slot: { [branch]: { epoch, at, files } }. On disk under
|
|
35
|
+
* <dataDir>/turn-sync/<room>/ (gzipped JSON, one file per agent and slot), in memory without one.
|
|
36
|
+
*/
|
|
37
|
+
export class CheckpointStore {
|
|
38
|
+
constructor ({ dir = null } = {}) {
|
|
39
|
+
this.dir = dir
|
|
40
|
+
this.mem = new Map()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
file (account, slot) { return path.join(this.dir, `${sha1(`${account}\0${slot}`)}.json.gz`) }
|
|
44
|
+
|
|
45
|
+
get (account, slot) {
|
|
46
|
+
const k = `${account}\0${slot}`
|
|
47
|
+
if (this.mem.has(k)) return this.mem.get(k)
|
|
48
|
+
let v = {}
|
|
49
|
+
if (this.dir) {
|
|
50
|
+
try { v = JSON.parse(zlib.gunzipSync(fs.readFileSync(this.file(account, slot))).toString('utf8')) || {} } catch {}
|
|
51
|
+
}
|
|
52
|
+
this.mem.set(k, v)
|
|
53
|
+
return v
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
put (account, slot, value) {
|
|
57
|
+
this.mem.set(`${account}\0${slot}`, value)
|
|
58
|
+
if (!this.dir) return
|
|
59
|
+
fs.mkdirSync(this.dir, { recursive: true })
|
|
60
|
+
const f = this.file(account, slot)
|
|
61
|
+
fs.writeFileSync(`${f}.tmp`, zlib.gzipSync(JSON.stringify(value)))
|
|
62
|
+
fs.renameSync(`${f}.tmp`, f)
|
|
63
|
+
this.prune()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Forgets checkpoints nobody synced against for KEEP_MS. */
|
|
67
|
+
prune (now = Date.now()) {
|
|
68
|
+
if (!this.dir) return
|
|
69
|
+
let names = []
|
|
70
|
+
try { names = fs.readdirSync(this.dir) } catch { return }
|
|
71
|
+
for (const n of names) {
|
|
72
|
+
const f = path.join(this.dir, n)
|
|
73
|
+
try { if (now - fs.statSync(f).mtimeMs > KEEP_MS) fs.rmSync(f, { force: true }) } catch {}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The room's checkpoint store, made the first time it is needed. */
|
|
79
|
+
export function checkpointsOf (room) {
|
|
80
|
+
if (!room.turnSync) room.turnSync = new CheckpointStore({ dir: room.dataDirOf ? path.join(room.dataDirOf, 'turn-sync', room.name) : null })
|
|
81
|
+
return room.turnSync
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Removes a room's checkpoints (the room's data is being removed). */
|
|
85
|
+
export function dropCheckpoints (dataDir, room) {
|
|
86
|
+
if (dataDir) fs.rmSync(path.join(dataDir, 'turn-sync', room), { recursive: true, force: true })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The session's files on a branch: path -> { h, text } (text files and small binaries) or { h, stored }. */
|
|
90
|
+
export function sessionState (files, blobs) {
|
|
91
|
+
const out = new Map()
|
|
92
|
+
for (const [rel, t] of files) {
|
|
93
|
+
if (!isSafeRelPath(rel) || !t) continue
|
|
94
|
+
const text = t.toString()
|
|
95
|
+
out.set(rel, sessionEntry(sha1(text), { text }))
|
|
96
|
+
}
|
|
97
|
+
for (const [rel, b] of blobs) {
|
|
98
|
+
if (!isSafeRelPath(rel) || out.has(rel) || !b || !HASH.test(String(b.hash || ''))) continue
|
|
99
|
+
out.set(rel, b.stored ? { h: b.hash, stored: true } : sessionEntry(b.hash))
|
|
100
|
+
}
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The folder's manifest from a request: { path: sha1 | 'skip' }, safe paths only. */
|
|
105
|
+
export function cleanManifest (files) {
|
|
106
|
+
const out = {}
|
|
107
|
+
for (const [rel, h] of Object.entries(files || {})) {
|
|
108
|
+
if (!isSafeRelPath(rel)) continue
|
|
109
|
+
if (h === SKIP || HASH.test(String(h))) out[rel] = h
|
|
110
|
+
}
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A file's content sent in a push: { buf, text, hash } or { gone: true }; null when malformed or too big. */
|
|
115
|
+
function content (v) {
|
|
116
|
+
if (!v || typeof v !== 'object') return null
|
|
117
|
+
if (v.deleted === true) return { gone: true }
|
|
118
|
+
if (typeof v.text === 'string') {
|
|
119
|
+
const buf = Buffer.from(v.text, 'utf8')
|
|
120
|
+
if (buf.length > MAX_SYNC_TEXT) return null
|
|
121
|
+
return { text: v.text, buf, hash: sha1(buf) }
|
|
122
|
+
}
|
|
123
|
+
if (typeof v.base64 === 'string') {
|
|
124
|
+
const buf = Buffer.from(v.base64, 'base64')
|
|
125
|
+
if (buf.length >= LARGE_FILE_BYTES) return null
|
|
126
|
+
return { buf, hash: sha1(buf) }
|
|
127
|
+
}
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* One step of a sync. `args`: { step, slot, files?, paths? }. `ctx`: { room, fdoc, files, blobs,
|
|
133
|
+
* branchKey, epoch, account, me, origin, globalRefusal(), pathRefusal(rel) -> { kind: 'aside' |
|
|
134
|
+
* 'claimed', why, by? } | null, record({ rel, kind, before, after, detail }), lastEditor(rel), ghosts? }.
|
|
135
|
+
* `ghosts`: the paths someone else has boxed (docs/superpowers/specs/2026-10-16-git-boxes-design.md): their
|
|
136
|
+
* unfinished work never reaches this folder. The folder sees each as it last had it (its checkpoint), or as it
|
|
137
|
+
* has it now; an edit to one is refused like one to a held file, and the folder's version comes back.
|
|
138
|
+
* Returns the answer (a plain object, sent as JSON).
|
|
139
|
+
*/
|
|
140
|
+
export function turnSyncStep (args, ctx) {
|
|
141
|
+
const slot = String(args.slot || '')
|
|
142
|
+
if (!SLOT.test(slot)) throw new Error('slot must be 8 to 64 letters, numbers, dashes or underscores (from your QUILT_AGENT_KEY)')
|
|
143
|
+
const store = checkpointsOf(ctx.room)
|
|
144
|
+
const all = store.get(ctx.account, slot)
|
|
145
|
+
const saved = all[ctx.branchKey]
|
|
146
|
+
// A checkpoint of an older document of this branch (removed and started again) says nothing about this one.
|
|
147
|
+
const checkpoint = saved && (saved.epoch || null) === (ctx.epoch || null) ? saved : null
|
|
148
|
+
const ghosts = ctx.ghosts instanceof Set ? ctx.ghosts : new Set()
|
|
149
|
+
const deleted = (rel) => !ghosts.has(rel) && !ctx.files.has(rel) && !ctx.blobs.has(rel) && (ctx.files._map.has(rel) || ctx.blobs._map.has(rel))
|
|
150
|
+
const cpFiles = (checkpoint && checkpoint.files) || {}
|
|
151
|
+
// The session as this folder may see it: someone else's box shows as the folder's own version.
|
|
152
|
+
const session = (local = null) => {
|
|
153
|
+
const out = sessionState(ctx.files, ctx.blobs)
|
|
154
|
+
for (const rel of ghosts) {
|
|
155
|
+
const cp = cpFiles[rel]
|
|
156
|
+
if (cp && HASH.test(String(cp.h || ''))) out.set(rel, sessionEntry(cp.h, { text: typeof cp.t === 'string' ? cp.t : null }))
|
|
157
|
+
else if (local && local[rel] && local[rel] !== SKIP) out.set(rel, sessionEntry(local[rel]))
|
|
158
|
+
else out.delete(rel)
|
|
159
|
+
}
|
|
160
|
+
return out
|
|
161
|
+
}
|
|
162
|
+
const step = args.step
|
|
163
|
+
|
|
164
|
+
if (step === 'plan') {
|
|
165
|
+
const local = cleanManifest(args.files)
|
|
166
|
+
const p = planSync({ local, session: session(local), checkpoint, deleted })
|
|
167
|
+
const refused = p.upload.length || p.delete.length ? ctx.globalRefusal() : null
|
|
168
|
+
return { step, ...p, ...(refused ? { refused } : {}) }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (step === 'push') {
|
|
172
|
+
const out = { step, pushed: [], deleted: [], merged: [], merges: [], pull: [], remove: [], aside: [], kept: [], skipped: [] }
|
|
173
|
+
const refusedAll = ctx.globalRefusal()
|
|
174
|
+
const first = !checkpoint
|
|
175
|
+
const base = (checkpoint && checkpoint.files) || {}
|
|
176
|
+
// Each path is only changed once, after it was looked at: one look at the session is enough.
|
|
177
|
+
const state = session()
|
|
178
|
+
for (const [rel, v] of Object.entries(args.files || {})) {
|
|
179
|
+
if (!isSafeRelPath(rel)) continue
|
|
180
|
+
const c = content(v)
|
|
181
|
+
if (!c) { out.skipped.push(rel); continue }
|
|
182
|
+
// Someone else's box this folder never synced: left as it is here, and not taken into the session.
|
|
183
|
+
if (ghosts.has(rel) && !cpFiles[rel]) { const why = ctx.pathRefusal(rel); out.kept.push({ path: rel, why: why ? why.why : `${rel} is someone else's unfinished work` }); continue }
|
|
184
|
+
const now = state.get(rel) || null
|
|
185
|
+
const d = decide({ local: c.gone ? null : c.hash, session: now, base: base[rel] || null, first, deleted: deleted(rel) })
|
|
186
|
+
if (d === 'same') continue
|
|
187
|
+
if (d === 'skip') { out.skipped.push(rel); continue }
|
|
188
|
+
if (d === 'pull' || d === 'remove') { out[d].push(rel); continue }
|
|
189
|
+
if (d === 'aside-pull' || d === 'aside-remove') { out.aside.push(rel); out[d.slice(6)].push(rel); continue }
|
|
190
|
+
// What follows changes the session: refused as a whole (an unanswered message, a full session), or for this file.
|
|
191
|
+
if (refusedAll) { out.kept.push({ path: rel, why: refusedAll }); continue }
|
|
192
|
+
const why = ctx.pathRefusal(rel)
|
|
193
|
+
if (why && why.kind === 'keep') { out.kept.push({ path: rel, why: why.why }); continue }
|
|
194
|
+
if (why || d === 'merge') {
|
|
195
|
+
settle(rel, c, now, base[rel] || null, why, ctx, out)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
if (d === 'push') { write(rel, c, ctx); out.pushed.push(rel) } else if (d === 'delete') { remove(rel, ctx); out.deleted.push(rel) }
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (step === 'pull') {
|
|
204
|
+
const now = session()
|
|
205
|
+
const files = {}
|
|
206
|
+
const more = []
|
|
207
|
+
let bytes = 0
|
|
208
|
+
for (const rel of Array.isArray(args.paths) ? args.paths : []) {
|
|
209
|
+
if (typeof rel !== 'string' || !isSafeRelPath(rel)) continue
|
|
210
|
+
const s = now.get(rel)
|
|
211
|
+
if (bytes >= MAX_PULL_BYTES) { more.push(rel); continue }
|
|
212
|
+
// Someone else's box: the folder's own last version when it has the text, else nothing (the folder keeps what it has).
|
|
213
|
+
if (ghosts.has(rel)) { if (s && typeof s.text === 'string') { files[rel] = { text: s.text, h: s.h }; bytes += s.text.length } continue }
|
|
214
|
+
if (!s) { files[rel] = { gone: true }; continue }
|
|
215
|
+
if (s.stored) { files[rel] = { stored: true }; continue }
|
|
216
|
+
if (typeof s.text === 'string') { files[rel] = { text: s.text, h: s.h }; bytes += s.text.length; continue }
|
|
217
|
+
const b = ctx.blobs.get(rel)
|
|
218
|
+
files[rel] = { base64: b.data || '', h: s.h }
|
|
219
|
+
bytes += (b.data || '').length
|
|
220
|
+
}
|
|
221
|
+
return { step, files, more }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (step === 'done') {
|
|
225
|
+
const local = cleanManifest(args.files)
|
|
226
|
+
const now = session(local)
|
|
227
|
+
const next = nextCheckpoint(checkpoint, local, now)
|
|
228
|
+
store.put(ctx.account, slot, { ...all, [ctx.branchKey]: { epoch: ctx.epoch || null, ...next } })
|
|
229
|
+
return { step, saved: Object.keys(next.files).length, behind: behind(local, now).slice(0, 200) }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
throw new Error('step must be plan, push, pull or done')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Writes the folder's version of rel into the session, as the agent. */
|
|
236
|
+
function write (rel, c, ctx) {
|
|
237
|
+
ctx.fdoc.transact(() => {
|
|
238
|
+
const existed = ctx.files.has(rel) || ctx.blobs.has(rel)
|
|
239
|
+
const before = ctx.files.get(rel)?.toString() ?? ''
|
|
240
|
+
if (typeof c.text === 'string') {
|
|
241
|
+
ctx.blobs.delete(rel)
|
|
242
|
+
let t = ctx.files.get(rel)
|
|
243
|
+
if (!t) { t = new Y.Text(); ctx.files.set(rel, t) }
|
|
244
|
+
const detail = applyTextDiff(t, c.text)
|
|
245
|
+
ctx.record({ rel, kind: existed ? 'edited' : 'created', before, after: c.text, detail })
|
|
246
|
+
} else {
|
|
247
|
+
ctx.files.delete(rel)
|
|
248
|
+
ctx.blobs.set(rel, { hash: c.hash, data: c.buf.toString('base64') })
|
|
249
|
+
ctx.record({ rel, kind: existed ? 'edited' : 'created', detail: `${c.buf.length} bytes` })
|
|
250
|
+
}
|
|
251
|
+
}, ctx.origin)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Deletes rel from the session, as the agent. */
|
|
255
|
+
function remove (rel, ctx) {
|
|
256
|
+
ctx.fdoc.transact(() => {
|
|
257
|
+
const before = ctx.files.get(rel)?.toString()
|
|
258
|
+
ctx.files.delete(rel)
|
|
259
|
+
ctx.blobs.delete(rel)
|
|
260
|
+
ctx.record({ rel, kind: 'deleted', ...(typeof before === 'string' ? { before, after: '' } : {}), detail: '' })
|
|
261
|
+
}, ctx.origin)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Both changed rel (or it isn't ours to change): merged when the lines don't overlap and nothing
|
|
266
|
+
* stands in the way; otherwise a merge record (as Session.openConflict: the session's version
|
|
267
|
+
* stays, ours is in the record) and the folder takes the session's version with ours kept aside.
|
|
268
|
+
*/
|
|
269
|
+
function settle (rel, c, now, base, why, ctx, out) {
|
|
270
|
+
const theirs = now && typeof now.text === 'string' ? now.text : null
|
|
271
|
+
if (!why && typeof c.text === 'string' && theirs !== null && base && typeof base.t === 'string') {
|
|
272
|
+
const merged = mergeTexts(base.t, c.text, theirs)
|
|
273
|
+
if (merged !== null) {
|
|
274
|
+
write(rel, { text: merged }, ctx)
|
|
275
|
+
out.merged.push(rel)
|
|
276
|
+
if (sha1(Buffer.from(merged, 'utf8')) !== c.hash) out.pull.push(rel)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (why && why.kind === 'aside') {
|
|
281
|
+
// Not ours to change (a viewer, a folder outside our scope): the session's version comes back.
|
|
282
|
+
out.aside.push(rel)
|
|
283
|
+
out[now ? 'pull' : 'remove'].push(rel)
|
|
284
|
+
out.kept.push({ path: rel, why: why.why })
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
const binary = typeof c.text !== 'string' && !c.gone
|
|
288
|
+
const textOf = (t) => (typeof t === 'string' && t.length <= MAX_RECORD_TEXT ? t : null)
|
|
289
|
+
const theirsKey = now ? (typeof now.text === 'string' ? now.text : `bin:${now.h}`) : null
|
|
290
|
+
const by = ctx.lastEditor ? ctx.lastEditor(rel) : null
|
|
291
|
+
const rec = openMerge(ctx.fdoc, ctx.fdoc.getMap('merges'), {
|
|
292
|
+
path: rel,
|
|
293
|
+
by: ctx.me,
|
|
294
|
+
byId: ctx.account,
|
|
295
|
+
others: by && by !== ctx.me ? [by] : [],
|
|
296
|
+
kind: why && why.kind === 'claimed' ? 'claimed' : 'conflict',
|
|
297
|
+
ours: binary || c.gone ? null : textOf(c.text),
|
|
298
|
+
oursDeleted: !!c.gone,
|
|
299
|
+
base: binary ? null : textOf(base && base.t),
|
|
300
|
+
theirsHash: theirsKey === null ? null : sha1(theirsKey),
|
|
301
|
+
binary,
|
|
302
|
+
claimedBy: why && why.kind === 'claimed' ? why.by || null : null
|
|
303
|
+
}, ctx.origin)
|
|
304
|
+
out.merges.push({ path: rel, id: rec.id, kind: rec.kind })
|
|
305
|
+
if (!c.gone) out.aside.push(rel)
|
|
306
|
+
out[now ? 'pull' : 'remove'].push(rel)
|
|
307
|
+
}
|