heyquilt 0.3.122 → 0.3.140

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.
Files changed (166) hide show
  1. package/RELEASES.md +279 -17
  2. package/bin/quilt.js +62 -152
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -2
  5. package/src/account.js +0 -29
  6. package/src/agent-task-workflow.js +2 -2
  7. package/src/agents/common.js +13 -0
  8. package/src/ai-vm.js +9 -1
  9. package/src/api/activity.js +5 -0
  10. package/src/api/memory-store.js +16 -38
  11. package/src/api/notify.js +134 -0
  12. package/src/api/routes/browsers.js +29 -0
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/session-migrate.js +7 -4
  15. package/src/api/routes/sessions.js +73 -1
  16. package/src/api/routes/teams.js +0 -23
  17. package/src/api/server.js +17 -25
  18. package/src/api/supabase-store.js +43 -56
  19. package/src/autowork.js +182 -16
  20. package/src/branch-view.js +6 -131
  21. package/src/connection.js +36 -9
  22. package/src/control.js +131 -48
  23. package/src/duties.js +1 -1
  24. package/src/force-date.js +27 -0
  25. package/src/fsutil.js +13 -3
  26. package/src/github-commit.js +3 -315
  27. package/src/history.js +13 -3
  28. package/src/mcp.js +93 -167
  29. package/src/merge3.js +11 -3
  30. package/src/now-summary.js +113 -0
  31. package/src/pathrules.js +12 -0
  32. package/src/protocol.js +10 -2
  33. package/src/quilt-default.js +84 -0
  34. package/src/quilt-history.js +106 -0
  35. package/src/quilt-remote.js +78 -0
  36. package/src/relay-ask.js +3 -66
  37. package/src/relay-cluster.js +3 -5
  38. package/src/relay-lease.js +6 -2
  39. package/src/relay-mcp.js +154 -432
  40. package/src/relay-prune.js +6 -145
  41. package/src/relay-turn-sync.js +1 -1
  42. package/src/release-words.js +2 -2
  43. package/src/runner.js +20 -7
  44. package/src/seam-access.js +97 -0
  45. package/src/seam-claims.js +53 -0
  46. package/src/seam-url.js +15 -0
  47. package/src/server.js +1516 -1383
  48. package/src/session-follow.js +461 -27
  49. package/src/session-layout3.js +410 -4
  50. package/src/session-migrate.js +15 -11
  51. package/src/session-quilt.js +1 -1
  52. package/src/session-seam.js +233 -20
  53. package/src/session.js +745 -3438
  54. package/src/source-live/bring-in.js +283 -0
  55. package/src/source-live/live-block.js +504 -33
  56. package/src/source-live/live-square.js +16 -5
  57. package/src/status.js +7 -58
  58. package/src/turn-sync.js +15 -2
  59. package/src/ui/agent-guide.js +1 -1
  60. package/src/ui/app.css +85 -20
  61. package/src/ui/app.js +4 -1
  62. package/src/ui/changes.js +5 -14
  63. package/src/ui/chat.js +9 -4
  64. package/src/ui/cloud-ai-model.js +13 -0
  65. package/src/ui/cloud-ai.js +10 -2
  66. package/src/ui/common.js +6 -4
  67. package/src/ui/fileview.js +20 -7
  68. package/src/ui/force-words.js +19 -0
  69. package/src/ui/git-rule.js +9 -28
  70. package/src/ui/home.js +3 -223
  71. package/src/ui/loom-model.js +15 -1
  72. package/src/ui/quiltview.js +213 -0
  73. package/src/ui/seam-model.js +251 -0
  74. package/src/ui/session.js +377 -489
  75. package/src/ui/tree.js +2 -8
  76. package/src/ui/voice-call.js +83 -14
  77. package/src/ui/voice-model.js +39 -0
  78. package/src/ui/voice.js +167 -17
  79. package/src/ui/workspaces.js +2 -122
  80. package/src/ui-server.js +81 -66
  81. package/src/update-check.js +25 -1
  82. package/src/voice.js +13 -6
  83. package/src/api/routes/github.js +0 -393
  84. package/src/branches.js +0 -194
  85. package/src/clash.js +0 -128
  86. package/src/commit-request.js +0 -59
  87. package/src/commit.js +0 -213
  88. package/src/git-hook.js +0 -48
  89. package/src/git.js +0 -147
  90. package/src/gitignore.js +0 -49
  91. package/src/gitstate.js +0 -1033
  92. package/src/quilts/AGENTS.md +0 -1
  93. package/src/quilts/CLAUDE.md +0 -1
  94. package/src/quilts/accounts.js +0 -136
  95. package/src/quilts/affected.js +0 -132
  96. package/src/quilts/archiver.js +0 -175
  97. package/src/quilts/autorevert.js +0 -49
  98. package/src/quilts/bisect-run.js +0 -31
  99. package/src/quilts/blame.js +0 -103
  100. package/src/quilts/block.js +0 -347
  101. package/src/quilts/bucket.js +0 -25
  102. package/src/quilts/cbor.js +0 -92
  103. package/src/quilts/checks.js +0 -308
  104. package/src/quilts/claims.js +0 -144
  105. package/src/quilts/cli.js +0 -536
  106. package/src/quilts/client.js +0 -137
  107. package/src/quilts/conflict-format.js +0 -190
  108. package/src/quilts/describe.js +0 -296
  109. package/src/quilts/dogfood.js +0 -82
  110. package/src/quilts/errors.js +0 -8
  111. package/src/quilts/events.js +0 -171
  112. package/src/quilts/export-git.js +0 -130
  113. package/src/quilts/export-remote.js +0 -156
  114. package/src/quilts/export.js +0 -81
  115. package/src/quilts/fastmerge.js +0 -41
  116. package/src/quilts/hash.js +0 -44
  117. package/src/quilts/history.js +0 -266
  118. package/src/quilts/ids.js +0 -12
  119. package/src/quilts/import-follow.js +0 -236
  120. package/src/quilts/import-git.js +0 -294
  121. package/src/quilts/import.js +0 -196
  122. package/src/quilts/index.js +0 -94
  123. package/src/quilts/keys.js +0 -186
  124. package/src/quilts/limits.js +0 -67
  125. package/src/quilts/lines.js +0 -49
  126. package/src/quilts/live.js +0 -50
  127. package/src/quilts/merge-smart.js +0 -114
  128. package/src/quilts/merge.js +0 -501
  129. package/src/quilts/oauth.js +0 -202
  130. package/src/quilts/objects.js +0 -144
  131. package/src/quilts/pack.js +0 -118
  132. package/src/quilts/records.js +0 -211
  133. package/src/quilts/resolutions.js +0 -69
  134. package/src/quilts/reviews.js +0 -208
  135. package/src/quilts/routes.js +0 -186
  136. package/src/quilts/runner.js +0 -95
  137. package/src/quilts/server.js +0 -144
  138. package/src/quilts/service.js +0 -963
  139. package/src/quilts/setup.js +0 -41
  140. package/src/quilts/signing.js +0 -149
  141. package/src/quilts/squares.js +0 -239
  142. package/src/quilts/store-memory.js +0 -844
  143. package/src/quilts/store-rpc.js +0 -90
  144. package/src/quilts/store-supabase.js +0 -13
  145. package/src/quilts/tags.js +0 -64
  146. package/src/quilts/textdiff.js +0 -96
  147. package/src/quilts/tokens.js +0 -66
  148. package/src/quilts/tools.js +0 -403
  149. package/src/quilts/tree.js +0 -351
  150. package/src/quilts/webhook.js +0 -116
  151. package/src/quilts/window.js +0 -121
  152. package/src/quilts/xdiff.js +0 -435
  153. package/src/relay-ci.js +0 -111
  154. package/src/relay-clash.js +0 -214
  155. package/src/relay-commit.js +0 -918
  156. package/src/relay-filekey.js +0 -46
  157. package/src/relay-git.js +0 -274
  158. package/src/relay-githttp.js +0 -166
  159. package/src/relay-github.js +0 -109
  160. package/src/relay-outbound.js +0 -1295
  161. package/src/relay-repo-moves.js +0 -43
  162. package/src/relay-sync.js +0 -795
  163. package/src/relay-upstream.js +0 -889
  164. package/src/sync-words.js +0 -42
  165. package/src/ui/branches.js +0 -111
  166. package/src/upstream.js +0 -192
@@ -1,41 +0,0 @@
1
- // Which store and object store Quilt source control runs on, from the environment.
2
- // QUILT_SOURCE=1 turns it on (off: the API has no /v1/quilts routes)
3
- // QUILT_SOURCE_DATA=<dir> local: the memory store's journal and the packs on disk (default ./quilt-source-data)
4
- // QUILT_SOURCE_STORE=supabase rows and packs in their own Supabase project ("Quilt Source"), apart
5
- // from the accounts project: QUILT_SOURCE_SUPABASE_URL and
6
- // QUILT_SOURCE_SUPABASE_KEY (its service role key); the rows from the
7
- // *_quilts.sql migration, the packs in its bucket QUILT_SOURCE_BUCKET
8
- // (default quilt-packs)
9
- // SEAM_IMPORT_SCRATCH=<dir> where imports from a git remote keep their mirrors (default the temp folder)
10
- // SEAM_SECRET_<NAME> a credential an import names (`--credential name`): user:token or a token
11
- import path from 'node:path'
12
- import os from 'node:os'
13
- import { createQuilts } from './index.js'
14
- import { createMemoryQuiltStore } from './store-memory.js'
15
- import { BlobstoreObjects, diskObjects } from './objects.js'
16
-
17
- export async function quiltsFromEnv (env, { memory = false, log = () => {} } = {}) {
18
- if (!/^(1|true|yes)$/i.test(env.QUILT_SOURCE || '')) return null
19
- if (env.QUILT_SOURCE_STORE === 'supabase') {
20
- const { createSupabaseQuiltStore } = await import('./store-supabase.js')
21
- const { SupabaseBucket } = await import('./bucket.js')
22
- // Never the accounts project: its load and its mistakes stay away from people's accounts.
23
- for (const k of ['QUILT_SOURCE_SUPABASE_URL', 'QUILT_SOURCE_SUPABASE_KEY']) if (!env[k]) throw new Error(`QUILT_SOURCE_STORE=supabase needs ${k}`)
24
- const url = env.QUILT_SOURCE_SUPABASE_URL
25
- const key = env.QUILT_SOURCE_SUPABASE_KEY
26
- if (env.SUPABASE_URL && url.replace(/\/+$/, '') === env.SUPABASE_URL.replace(/\/+$/, '')) throw new Error('QUILT_SOURCE_SUPABASE_URL must be its own project, not the accounts project')
27
- const store = createSupabaseQuiltStore({ url, serviceKey: key })
28
- const objects = new BlobstoreObjects(new SupabaseBucket({ url, key, bucket: env.QUILT_SOURCE_BUCKET || 'quilt-packs' }))
29
- log(`quilt source control: rows and packs in ${new URL(url).host}`)
30
- const quilts = createQuilts({ store, objects, events: { log }, siteUrl: env.SEAM_SITE_URL || undefined, importFollow: { scratch: env.SEAM_IMPORT_SCRATCH || path.join(os.tmpdir(), 'seam-imports') } })
31
- quilts.follower.start()
32
- return quilts
33
- }
34
- const dir = path.resolve(env.QUILT_SOURCE_DATA || './quilt-source-data')
35
- const store = createMemoryQuiltStore({ file: path.join(dir, 'journal.jsonl') })
36
- log(`quilt source control: local store in ${dir}${memory ? '' : ' (QUILT_SOURCE_DATA)'}`)
37
- // The local prototype may POST events to webhooks on this machine; the hosted one may not.
38
- const quilts = createQuilts({ store, objects: diskObjects(path.join(dir, 'packs')), events: { allowLocalWebhooks: true, log }, siteUrl: env.SEAM_SITE_URL || undefined, importFollow: { scratch: env.SEAM_IMPORT_SCRATCH || path.join(dir, 'imports'), allowLocal: true } })
39
- quilts.follower.start()
40
- return quilts
41
- }
@@ -1,149 +0,0 @@
1
- // Signed commits: a person or an agent adds an Ed25519 public key to their account, signs a commit
2
- // with the private key (which never leaves their computer), and anyone reading the log can check
3
- // the signature (seam log --show-signature). A commit's id is the hash of its whole record (tree,
4
- // parents, author, message, quilt), so signing the id signs all of it.
5
- //
6
- // What is signed is the text `seam-commit <id>\n`. Only the commit's author, or the person it was
7
- // made for (on_behalf_of), may sign it, with one of their own keys. A key is removed, never
8
- // deleted: signatures made with it then read as "removed_key", so a stolen key stops vouching.
9
- import crypto from 'node:crypto'
10
- import fs from 'node:fs'
11
- import os from 'node:os'
12
- import path from 'node:path'
13
- import { QuiltError } from './errors.js'
14
-
15
- export const MAX_KEYS = 20
16
- const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') // Ed25519 SubjectPublicKeyInfo header
17
-
18
- /** The bytes a signature covers. */
19
- export const signedText = (commitId) => `seam-commit ${commitId}\n`
20
-
21
- /** A public key (SPKI PEM, or base64 of the 32 raw bytes) as base64 of its raw bytes, or null. */
22
- export function rawPublicKey (input) {
23
- const s = String(input || '').trim()
24
- if (!s) return null
25
- try {
26
- if (s.includes('BEGIN')) {
27
- const k = crypto.createPublicKey(s)
28
- if (k.asymmetricKeyType !== 'ed25519') return null
29
- return k.export({ format: 'der', type: 'spki' }).subarray(SPKI_PREFIX.length).toString('base64')
30
- }
31
- const b = Buffer.from(s, 'base64')
32
- return b.length === 32 ? b.toString('base64') : null
33
- } catch { return null }
34
- }
35
-
36
- const keyObject = (raw) => crypto.createPublicKey({ key: Buffer.concat([SPKI_PREFIX, Buffer.from(raw, 'base64')]), format: 'der', type: 'spki' })
37
-
38
- /** A key's id: `ed25519:` and the first 16 hex of the sha256 of its raw bytes. */
39
- export const keyId = (raw) => 'ed25519:' + crypto.createHash('sha256').update(Buffer.from(raw, 'base64')).digest('hex').slice(0, 16)
40
-
41
- export function verifySignature (raw, commitId, signature) {
42
- try { return crypto.verify(null, Buffer.from(signedText(commitId)), keyObject(raw), Buffer.from(String(signature), 'base64')) } catch { return false }
43
- }
44
-
45
- // ---- the client side: the private key on this computer ----
46
-
47
- /** Where this computer's signing key lives: SEAM_SIGNING_KEY, else ~/.seam/signing-key.pem. */
48
- export const signingKeyFile = () => process.env.SEAM_SIGNING_KEY || path.join(process.env.SEAM_HOME || path.join(os.homedir(), '.seam'), 'signing-key.pem')
49
-
50
- /** Makes a new key pair on this computer (refused when one is there). Returns the public half. */
51
- export function makeLocalKey (file = signingKeyFile()) {
52
- if (fs.existsSync(file)) throw new Error(`There is already a signing key at ${file}.`)
53
- const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
54
- fs.mkdirSync(path.dirname(file), { recursive: true })
55
- fs.writeFileSync(file, privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })
56
- return publicKey.export({ format: 'pem', type: 'spki' })
57
- }
58
-
59
- /** This computer's key: { sign(commitId), public_key, key } or null when there is none. */
60
- export function localKey (file = signingKeyFile()) {
61
- if (!fs.existsSync(file)) return null
62
- const priv = crypto.createPrivateKey(fs.readFileSync(file))
63
- const pub = crypto.createPublicKey(priv).export({ format: 'pem', type: 'spki' })
64
- const raw = rawPublicKey(pub)
65
- return { public_key: pub, key: keyId(raw), sign: (commitId) => crypto.sign(null, Buffer.from(signedText(commitId)), priv).toString('base64') }
66
- }
67
-
68
- /** Signs a commit with this computer's key and sends the signature. */
69
- export async function signWithLocalKey (client, quilt, rev) {
70
- const k = localKey()
71
- if (!k) throw new Error('No signing key on this computer: run seam key add first.')
72
- const id = (await client.describe(quilt, rev || 'main')).commit
73
- if (!id) throw new Error(`No commit at ${rev || 'main'}.`)
74
- return client.sign(quilt, id, { key: k.key, signature: k.sign(id) })
75
- }
76
-
77
- // ---- the server side ----
78
-
79
- export function signingOps (svc) {
80
- const { store, now, quiltFor, resolve, getCommit, fromStore } = svc.internals
81
-
82
- const keyView = (r) => ({ key: r.id, name: r.name || '', public_key: r.public_key, added_at: r.added_at, ...(r.removed_at ? { removed_at: r.removed_at } : {}) })
83
-
84
- /** POST /signing-keys { public_key, name? }: adds one of your keys. */
85
- async function addKey (me, _ref, { public_key: pk, name = '' } = {}) {
86
- const raw = rawPublicKey(pk)
87
- if (!raw) throw new QuiltError(400, 'bad_request', 'public_key: an Ed25519 public key (PEM, or base64 of its 32 bytes)')
88
- if (String(name).length > 100) throw new QuiltError(400, 'bad_request', 'a key name is up to 100 characters')
89
- const mine = await store.signingKeys(me.member_id)
90
- if (mine.filter((k) => !k.removed_at).length >= MAX_KEYS) throw new QuiltError(409, 'too_many_keys', `up to ${MAX_KEYS} keys each`)
91
- try {
92
- return keyView(await store.addSigningKey({ id: keyId(raw), member: me.member_id, public_key: raw, name: String(name), added_at: now() }))
93
- } catch (err) { throw fromStore(err) }
94
- }
95
-
96
- /** GET /signing-keys: your keys (or a member's, with ?member=). */
97
- async function keys (me, _ref, { member } = {}) {
98
- return { member: member || me.member_id, keys: (await store.signingKeys(member || me.member_id)).map(keyView) }
99
- }
100
-
101
- /** DELETE /signing-keys/:key: removes one of your keys; its signatures stop counting. */
102
- async function removeKey (me, _ref, key) {
103
- const row = await store.signingKey(String(key))
104
- if (!row || row.member !== me.member_id) throw new QuiltError(404, 'not_found', `you have no key ${key}`)
105
- if (row.removed_at) return keyView(row)
106
- try { return keyView(await store.removeSigningKey({ id: row.id, member: me.member_id, at: now() })) } catch (err) { throw fromStore(err) }
107
- }
108
-
109
- /** POST /commits/:rev/signatures { key, signature }: signs a commit you made (or that was made for you). */
110
- async function sign (me, ref, rev, { key, signature } = {}) {
111
- const q = await quiltFor(me, ref, 'write')
112
- const id = await resolve(q.id, String(rev || ''))
113
- if (!id) throw new QuiltError(404, 'not_found', `no commit at ${rev}`)
114
- const c = await getCommit(q.id, id)
115
- const by = c.author?.member_id
116
- if (me.member_id !== by && me.member_id !== c.on_behalf_of) throw new QuiltError(403, 'not_author', 'only the commit\'s author, or whom it was made for, signs it')
117
- const row = key ? await store.signingKey(String(key)) : null
118
- if (!row || row.member !== me.member_id || row.removed_at) throw new QuiltError(400, 'bad_key', `${key || 'key'} is not one of your keys (seam key add)`)
119
- if (!verifySignature(row.public_key, id, signature)) throw new QuiltError(422, 'bad_signature', `the signature does not match ${id.slice(0, 12)} and ${row.id}`)
120
- try {
121
- await store.addSignature({ quilt_id: q.id, commit: id, key: row.id, signer: me.member_id, signature: String(signature), at: now() })
122
- } catch (err) { throw fromStore(err) }
123
- return { commit: id, short: id.slice(0, 12), signature: { status: 'good', key: row.id, signer: me.member_id } }
124
- }
125
-
126
- /** Each commit's signature, checked now: good, bad, removed_key, or none. */
127
- async function signaturesFor (q, commits) {
128
- const rows = await store.signaturesOf(q, commits.map((c) => c.id))
129
- const keyRows = new Map()
130
- const out = new Map()
131
- for (const r of rows) {
132
- if (!keyRows.has(r.key)) keyRows.set(r.key, await store.signingKey(r.key))
133
- const k = keyRows.get(r.key)
134
- const status = !k || !verifySignature(k.public_key, r.commit, r.signature) ? 'bad' : k.removed_at ? 'removed_key' : 'good'
135
- const prev = out.get(r.commit)
136
- if (!prev || (status === 'good' && prev.status !== 'good')) out.set(r.commit, { status, key: r.key, signer: r.signer, at: r.at })
137
- }
138
- return out
139
- }
140
-
141
- /** Adds `signature` to each commit of a log answer. */
142
- async function withSignatures (me, ref, answer) {
143
- const q = await quiltFor(me, ref, 'read')
144
- const sigs = await signaturesFor(q.id, answer.commits || [])
145
- return { ...answer, commits: (answer.commits || []).map((c) => ({ ...c, signature: sigs.get(c.id) || { status: 'none' } })) }
146
- }
147
-
148
- return { addKey, signingKeys: keys, removeKey, sign, withSignatures }
149
- }
@@ -1,239 +0,0 @@
1
- // The rest of a square's lifecycle (plans/quilt-source-control/06): handoff, abandon, reopen,
2
- // fold, split, merging one square into another (07), and status's merged_under_you and
3
- // would_conflict (06). Each state move is one square log row and a compare-and-swap on the
4
- // square row (store.setSquare expect).
5
- import { QuiltError, uuidv7, squareShort } from './service.js'
6
- import { under, cleanPrefix } from './records.js'
7
-
8
- const MAX_HANDOFF_TEXT = 2000
9
-
10
- export function squareOps (svc, m = {}) {
11
- const { store, now, quiltFor, squareByRef, squareView, fromStore, resolve, getCommit, snapshot, reader, seal, writable, once, commitView, pathPatches } = svc.internals
12
- const move = async (args) => { try { return await store.setSquare(args) } catch (e) { throw fromStore(e) } }
13
-
14
- /** handoff: { to, expect_head?, note? } — the holder (or an admin) gives the square to another member. */
15
- async function handoff (me, ref, sqRef, body = {}) {
16
- const q = await quiltFor(me, ref, 'write')
17
- const sq = await squareByRef(q.id, sqRef)
18
- if (sq.holder !== me.member_id && q.level !== 'admin') throw new QuiltError(403, 'not_holder', `this square is held by ${sq.holder}`, { holder: sq.holder })
19
- if (sq.state !== 'open') throw new QuiltError(409, 'square_state', `the square is ${sq.state}`)
20
- const to = String(body.to || '')
21
- if (!to) throw new QuiltError(400, 'bad_request', 'give `to`: the member to hand the square to')
22
- const level = await store.memberLevel(q.id, to)
23
- if (level !== 'write' && level !== 'admin') throw new QuiltError(403, 'forbidden', `${to} cannot write to this quilt`)
24
- const note = String(body.note || body.context || '')
25
- if (note.length > MAX_HANDOFF_TEXT) throw new QuiltError(400, 'bad_request', `the note is at most ${MAX_HANDOFF_TEXT} characters`)
26
- const expect = { holder: sq.holder, state: 'open', ...(body.expect_head !== undefined ? { head: body.expect_head } : {}) }
27
- const row = await move({ square_id: sq.id, op: 'handoff', expect, actor: me.member_id, at: now(), patch: { holder: to, writers: (sq.writers || []).filter((w) => w !== to) }, log: { from: sq.holder, to, ...(note ? { context: note } : {}) } })
28
- return squareView(row)
29
- }
30
-
31
- /** abandon: { expect_head? } — the work is not wanted; the id stays, and it can be reopened. */
32
- async function abandon (me, ref, sqRef, body = {}) {
33
- const q = await quiltFor(me, ref, 'write')
34
- const sq = await squareByRef(q.id, sqRef)
35
- if (sq.holder !== me.member_id && q.level !== 'admin') throw new QuiltError(403, 'not_holder', `this square is held by ${sq.holder}`, { holder: sq.holder })
36
- if (sq.state !== 'open') throw new QuiltError(409, 'square_state', `the square is ${sq.state}`)
37
- const expect = { state: 'open', ...(body.expect_head !== undefined ? { head: body.expect_head } : {}) }
38
- return squareView(await move({ square_id: sq.id, op: 'abandon', expect, actor: me.member_id, at: now(), patch: { state: 'abandoned' }, log: { snapshot_commit: sq.head || null } }))
39
- }
40
-
41
- /** reopen: abandoned → open; the reopener becomes the holder. */
42
- async function reopen (me, ref, sqRef) {
43
- const q = await quiltFor(me, ref, 'write')
44
- const sq = await squareByRef(q.id, sqRef)
45
- if (sq.state !== 'abandoned') throw new QuiltError(409, 'square_state', `only an abandoned square reopens; this one is ${sq.state}`)
46
- return squareView(await move({ square_id: sq.id, op: 'reopen', expect: { state: 'abandoned' }, actor: me.member_id, at: now(), patch: { state: 'open', holder: me.member_id, writers: (sq.writers || []).filter((w) => w !== me.member_id) } }))
47
- }
48
-
49
- const authorOf = (me) => ({ member_id: me.member_id, kind: me.kind, name: me.name, ...(me.tool ? { tool: me.tool } : {}) })
50
- const toChange = (p, e) => !e ? { path: p, op: 'delete' } : e.mode === 'conflict' ? { path: p, op: 'conflict', term: e.term } : { path: p, op: 'put', blob: e.blob, mode: e.mode, size: e.size }
51
-
52
- /** Seals a made commit and writes it on its square (compare-and-swap on the head). */
53
- async function writeMade (q, made, expectHead, me, objects, payload, squarePatch) {
54
- await seal(q, [...objects, made.object], { by: me.member_id })
55
- await store.stageFiles({ quilt_id: q, commit_id: made.id, files: made.record.changes })
56
- try {
57
- return await store.commit({ commit: { ...made.record, id: made.id, at: now() }, expect_head: expectHead || null, actor: me.member_id, at: now(), payload, ...(squarePatch ? { square_patch: squarePatch } : {}) })
58
- } catch (err) { throw fromStore(err) }
59
- }
60
-
61
- /**
62
- * The merge base of two commits (07, 08): the common ancestor with the highest seq. Walks both
63
- * histories newest first (a parent's seq is always below its child's), so it reads only the
64
- * commits since the two sides parted. Returns { base, bases } (bases: candidates seen).
65
- */
66
- async function mergeBase (q, a, b) {
67
- if (!a || !b) return { base: null, bases: 0 }
68
- const flags = new Map(); const info = new Map(); const done = new Set(); const queue = []
69
- const mark = async (id, f) => {
70
- const cur = flags.get(id) || 0
71
- if ((cur | f) === cur) return
72
- flags.set(id, cur | f)
73
- if (!info.has(id)) { const c = await getCommit(q, id); info.set(id, { seq: c.seq, parents: c.parents }) }
74
- if (!done.has(id) && !queue.includes(id)) queue.push(id)
75
- }
76
- await mark(a, 1); await mark(b, 2)
77
- while (queue.length) {
78
- let at = 0
79
- for (let i = 1; i < queue.length; i++) if (info.get(queue[i]).seq > info.get(queue[at]).seq) at = i
80
- const id = queue.splice(at, 1)[0]
81
- done.add(id)
82
- const f = flags.get(id)
83
- if (f === 3) return { base: id, bases: 1 + queue.filter((x) => flags.get(x) === 3).length }
84
- for (const p of info.get(id).parents) await mark(p, f)
85
- }
86
- return { base: null, bases: 0 }
87
- }
88
-
89
- /**
90
- * merge into a square (07): { from: sq:… | revision, expect_head?, message? } writes a commit
91
- * in the square with parents [head, from] after a three-way merge against their merge base.
92
- * Conflicts are kept as terms in the result; `from` stays open (fold closes it).
93
- */
94
- async function mergeSquare (me, ref, intoRef, body = {}) {
95
- const q = await quiltFor(me, ref, 'write')
96
- const into = await writable(me, q, intoRef)
97
- const expect = body.expect_head === undefined ? into.head : body.expect_head
98
- if (!body.from) throw new QuiltError(400, 'bad_request', 'give `from`: the square (sq:…) or revision to merge in')
99
- const fromSq = /^sq:/.test(String(body.from)) || /^[0-9a-f-]{36}$/.test(String(body.from)) ? await squareByRef(q.id, body.from).catch(() => null) : null
100
- const theirs = fromSq ? (fromSq.head || fromSq.base) : await resolve(q.id, body.from)
101
- if (!theirs) throw new QuiltError(404, 'not_found', `${body.from} names no commit`)
102
- if (fromSq && fromSq.id === into.id) throw new QuiltError(400, 'bad_request', 'a square cannot merge itself')
103
- return once('merge-square', into.id, expect, { from: theirs, message: body.message || null }, async () => {
104
- if (into.state !== 'open') throw new QuiltError(409, into.state === 'merging' ? 'square_merging' : 'square_closed', `the square is ${into.state}`)
105
- if ((into.head || null) !== (expect || null)) throw new QuiltError(409, 'head_moved', 'the square moved on since you looked', { head: into.head })
106
- const ours = into.head || into.base
107
- const { base, bases } = await mergeBase(q.id, ours, theirs)
108
- if (base === theirs) return { ok: true, noop: true, detail: 'already merged: that work is in this square', square: await squareView(into) }
109
- const sides = { ours: { commit: ours, square: into.id, by: me.member_id }, theirs: { commit: theirs, ...(fromSq ? { square: fromSq.id, by: fromSq.holder } : { by: (await getCommit(q.id, theirs)).author.member_id }) }, base: { commit: base } }
110
- const comb = await m.combine(q.id, base, ours, theirs, sides, { reason: 'merge', against: theirs })
111
- const onto = await m.entryMap(q.id, ours, comb.finals.keys())
112
- const changes = m.changesOnto(onto, comb.finals)
113
- const label = fromSq ? `square ${squareShort(fromSq.id)} "${fromSq.title}"` : `${String(body.from)}`
114
- const made = m.makeCommit(q.id, into, { parents: [ours, theirs].filter(Boolean), author: authorOf(me), onBehalfOf: me.on_behalf_of, message: body.message || `Merge ${label} into ${squareShort(into.id)}`, changes })
115
- const row = await writeMade(q.id, made, into.head, me, [...comb.blobs, ...comb.terms], { merged_from: theirs, ...(fromSq ? { from_square: fromSq.id } : {}), base, bases })
116
- return { ok: true, seq: row.seq, result: commitView({ ...row, changes }), base, bases, combined: comb.combined, conflicted: comb.conflicts.length, terms: comb.conflicts, square: await squareView(await store.getSquare(into.id)) }
117
- })
118
- }
119
-
120
- /**
121
- * fold (06): { into, expect_head? } ends this square and keeps its work: one commit on `into`
122
- * whose change is this square's head against its base (predecessor = the head; overlaps become
123
- * conflict terms, D10), then this square is `folded` with `folded_into`.
124
- */
125
- async function fold (me, ref, fromRef, body = {}) {
126
- const q = await quiltFor(me, ref, 'write')
127
- const from = await squareByRef(q.id, fromRef)
128
- if (!body.into) throw new QuiltError(400, 'bad_request', 'give `into`: the square to fold this one into')
129
- const into = await squareByRef(q.id, body.into)
130
- if (from.id === into.id) throw new QuiltError(400, 'bad_request', 'a square cannot fold into itself')
131
- if (q.level !== 'admin' && (from.holder !== me.member_id || into.holder !== me.member_id)) throw new QuiltError(403, 'not_holder', 'fold needs you to hold both squares (hand the other one to yourself first)', { holders: [from.holder, into.holder] })
132
- for (const s of [from, into]) if (s.state !== 'open') throw new QuiltError(409, 'square_state', `${squareShort(s.id)} is ${s.state}; open a new square from the patch instead`)
133
- const expect = body.expect_head === undefined ? from.head : body.expect_head
134
- if ((from.head || null) !== (expect || null)) throw new QuiltError(409, 'head_moved', 'the square moved on since you looked', { head: from.head })
135
- return once('fold', from.id, expect, { into: into.id }, async () => {
136
- let commit = null; let terms = []
137
- if (from.head) {
138
- const ours = into.head || into.base
139
- const sides = { ours: { commit: ours, square: into.id, by: into.holder }, theirs: { commit: from.head, square: from.id, by: from.holder }, base: { commit: from.base } }
140
- const comb = await m.combine(q.id, from.base, ours, from.head, sides, { reason: 'merge', against: from.head })
141
- const finals = new Map([...comb.finals].filter(([p]) => comb.theirsPaths.has(p)))
142
- const changes = m.changesOnto(await m.entryMap(q.id, ours, finals.keys()), finals)
143
- if (changes.length) {
144
- const made = m.makeCommit(q.id, into, { parents: [ours].filter(Boolean), predecessor: from.head, author: authorOf(me), onBehalfOf: me.on_behalf_of, message: body.message || `Fold ${squareShort(from.id)}: ${from.title}`, changes })
145
- const row = await writeMade(q.id, made, into.head, me, [...comb.blobs, ...comb.terms], { folded_from: from.id, from_head: from.head })
146
- commit = commitView({ ...row, changes })
147
- terms = comb.conflicts.filter((c) => finals.has(c.path))
148
- }
149
- }
150
- const row = await move({ square_id: from.id, op: 'fold', expect: { state: 'open', head: from.head || null }, actor: me.member_id, at: now(), patch: { state: 'folded', folded_into: into.id }, log: { into: into.id, commit: commit ? commit.id : null } })
151
- return { ok: true, folded: await squareView(row), result: commit, conflicted: terms.length, terms, square: await squareView(await store.getSquare(into.id)) }
152
- })
153
- }
154
-
155
- /**
156
- * split (06): { by: 'paths', groups: [[path or dir, ...], ...], titles?, expect_head? }. Each
157
- * group of the square's changed paths moves to a new sibling square (same base and line) as one
158
- * commit with predecessor = the head; the largest group and the paths no group covers stay,
159
- * and the square's head is rewritten to that remainder.
160
- */
161
- async function split (me, ref, sqRef, body = {}) {
162
- const q = await quiltFor(me, ref, 'write')
163
- const sq = await writable(me, q, sqRef)
164
- if ((body.by || 'paths') !== 'paths') throw new QuiltError(400, 'bad_request', 'this prototype splits by paths only (claims and tasks are not on the quilt server yet)')
165
- if (sq.state !== 'open') throw new QuiltError(409, 'square_state', `the square is ${sq.state}`)
166
- const expect = body.expect_head === undefined ? sq.head : body.expect_head
167
- if ((sq.head || null) !== (expect || null)) throw new QuiltError(409, 'head_moved', 'the square moved on since you looked', { head: sq.head })
168
- if (!sq.head) throw new QuiltError(400, 'bad_request', 'the square has no commits to split')
169
- const groups = (body.groups || []).map((g) => (Array.isArray(g) ? g : [g]).map(cleanPrefix))
170
- if (groups.length < 1 || groups.some((g) => !g.length)) throw new QuiltError(400, 'bad_request', 'give groups: [[paths], ...], one per new square')
171
- return once('split', sq.id, expect, { groups, titles: body.titles || null }, async () => {
172
- const d = await reader(q.id).diff(await snapshot(q.id, sq.base), await snapshot(q.id, sq.head))
173
- const sets = groups.map(() => [])
174
- const rest = []
175
- for (const e of d) { const i = groups.findIndex((g) => g.some((p) => under(e.path, p))); (i < 0 ? rest : sets[i]).push(e) }
176
- const empty = sets.findIndex((s) => !s.length)
177
- if (empty >= 0) throw new QuiltError(400, 'bad_request', `group ${empty + 1} (${groups[empty].join(', ')}) covers no path the square changed`)
178
- // The keeper: the paths no group covers, or the largest group when every path is covered.
179
- let keep = -1
180
- if (!rest.length) { keep = 0; for (let i = 1; i < sets.length; i++) if (sets[i].length > sets[keep].length) keep = i }
181
- const kept = keep < 0 ? rest : [...sets[keep], ...rest]
182
- const opened = []
183
- for (let i = 0; i < sets.length; i++) {
184
- if (i === keep) continue
185
- const title = String((body.titles && body.titles[i]) || `${sq.title} (${groups[i].join(', ')})`).slice(0, 200)
186
- const ns = await store.openSquare({ square: { id: uuidv7(now()), quilt_id: q.id, title, line: sq.line, base: sq.base, holder: sq.holder, opened_by: me.member_id, writers: [], ...(sq.task_id ? { task_id: sq.task_id } : {}) }, at: now() })
187
- const changes = sets[i].map((e) => toChange(e.path, e.after || null))
188
- const made = m.makeCommit(q.id, ns, { parents: [sq.base].filter(Boolean), predecessor: sq.head, author: authorOf(me), onBehalfOf: me.on_behalf_of, message: `${title}\n\n(split from ${squareShort(sq.id)})`, changes })
189
- await writeMade(q.id, made, null, me, [], { split_from: sq.id, reason: 'split' })
190
- opened.push(Object.assign(ns, { group: i }))
191
- }
192
- try {
193
- const head = await getCommit(q.id, sq.head)
194
- const made = m.makeCommit(q.id, sq, { parents: [sq.base].filter(Boolean), predecessor: sq.head, author: authorOf(me), onBehalfOf: me.on_behalf_of, message: head.message, changes: kept.map((e) => toChange(e.path, e.after || null)) })
195
- await writeMade(q.id, made, sq.head, me, [], { split_into: opened.map((s) => s.id) })
196
- await move({ square_id: sq.id, op: 'split', actor: me.member_id, at: now(), patch: {}, log: { into: opened.map((s) => s.id), kept_paths: kept.length, blocks_moved: 0 } })
197
- } catch (err) {
198
- // The square moved under the split: the new squares go away again (abandoned), nothing else changed.
199
- for (const s of opened) await move({ square_id: s.id, op: 'abandon', actor: me.member_id, at: now(), patch: { state: 'abandoned' }, log: { reason: 'split_failed' } }).catch(() => {})
200
- throw err
201
- }
202
- return { ok: true, kept: kept.map((e) => e.path), square: await squareView(await store.getSquare(sq.id)), squares: await Promise.all(opened.map(async (s) => ({ ...(await squareView(await store.getSquare(s.id))), paths: sets[s.group].map((e) => e.path) }))) }
203
- })
204
- }
205
-
206
- /**
207
- * One square with 06's derived status: merged_under_you (patches since the base that changed a
208
- * path the square changed, newest first, at most 50 plus `more`) and would_conflict (07's merge
209
- * as a dry run against the tip: the paths that would carry a term, and the patch each meets).
210
- */
211
- async function square (me, ref, sqRef) {
212
- const q = await quiltFor(me, ref)
213
- return statusOf(q.id, await squareByRef(q.id, sqRef))
214
- }
215
-
216
- async function statusOf (qid, sq) {
217
- const q = { id: qid }
218
- const view = await squareView(sq)
219
- if (sq.state !== 'open' || !view.behind_by) return { ...view, merged_under_you: [], would_conflict: [] }
220
- const ln = await store.getLine(q.id, sq.line)
221
- const changed = sq.head ? (await reader(q.id).diff(await snapshot(q.id, sq.base), await snapshot(q.id, sq.head))).map((e) => e.path) : []
222
- if (!changed.length) return { ...view, merged_under_you: [], would_conflict: [] }
223
- const hit = await pathPatches(q.id, sq.line, changed.slice(0, 2000), ln.patch_count, sq.base, 50)
224
- const merged = []
225
- for (const id of hit.ids) {
226
- const c = await getCommit(q.id, id)
227
- const touched = c.changes.filter((ch) => changed.includes(ch.path)).map((ch) => ch.path)
228
- merged.push({ patch_no: c.patch?.patch_no, commit: id, author: c.author.member_id, ...(c.task_id ? { task_id: c.task_id } : {}), message: String(c.message || '').split('\n')[0], paths: touched })
229
- }
230
- const dry = await m.rebaseCommit(q.id, sq, sq.head, ln.tip, {})
231
- const wouldConflict = dry.conflicts.map((t) => {
232
- const by = merged.find((p) => p.paths.includes(t.path))
233
- return { path: t.path, kind: t.kind, ...(by ? { patch_no: by.patch_no, author: by.author } : {}) }
234
- })
235
- return { ...view, merged_under_you: merged, ...(hit.more ? { merged_under_you_more: true } : {}), would_conflict: wouldConflict }
236
- }
237
-
238
- return { handoff, abandon, reopen, mergeSquare, fold, split, square, mergeBase, statusOf }
239
- }