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,186 +0,0 @@
1
- // Seam's own keys for its own accounts (accounts.js):
2
- // - a computer linked through Seam's device flow (`seam login`): it shows a code, the person
3
- // approves it on seam.heyquilt.com/device, and the computer gets a key (ssk_…) that lasts
4
- // 30 days and is renewed with the computer's own Ed25519 key, no browser (`resume`);
5
- // - agents an account makes (`seam agent new <name>`), each with its own key (sak_…), which the
6
- // owner refreshes (a new key, the old one revoked) or revokes with the agent.
7
- // Only a key's sha256 is kept. An agent is the member agent:<id>, on behalf of its owner.
8
- import crypto from 'node:crypto'
9
- import { QuiltError } from './errors.js'
10
- import { uuidv7 } from './ids.js'
11
-
12
- export const DEVICE_KEY_PREFIX = 'ssk_'
13
- export const AGENT_KEY_PREFIX = 'sak_'
14
- export const DEVICE_LINK_CONTEXT = 'seam-device-link-v1'
15
- export const DEVICE_RESUME_CONTEXT = 'seam-device-resume-v1'
16
- export const DEVICE_KEY_DAYS = 30
17
- export const LINK_SECONDS = 600
18
- export const MAX_AGENTS = 100
19
- const KEY = /^s[sa]k_[A-Za-z0-9_-]{43}$/
20
- const USER_CODE_ABC = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
21
-
22
- export const isSeamKey = (s) => KEY.test(String(s || ''))
23
- const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex')
24
- const newSecret = (prefix) => prefix + crypto.randomBytes(32).toString('base64url')
25
- const userCode = () => { const b = crypto.randomBytes(8); let s = ''; for (let i = 0; i < 8; i++) s += USER_CODE_ABC[b[i] % USER_CODE_ABC.length]; return `${s.slice(0, 4)}-${s.slice(4)}` }
26
-
27
- function publicKeyOf (publicKey) {
28
- try {
29
- const k = crypto.createPublicKey({ key: Buffer.from(String(publicKey), 'base64url'), format: 'der', type: 'spki' })
30
- if (k.asymmetricKeyType !== 'ed25519') throw new Error('not ed25519')
31
- return k
32
- } catch { throw new QuiltError(400, 'bad_request', 'publicKey: this computer\'s Ed25519 public key (SPKI DER, base64url)') }
33
- }
34
- const signedBy = (publicKey, text, signature) => {
35
- try { return crypto.verify(null, Buffer.from(text), publicKeyOf(publicKey), Buffer.from(String(signature || ''), 'base64url')) } catch { return false }
36
- }
37
-
38
- export function keyOps (svc, { siteUrl = 'https://seam.heyquilt.com' } = {}) {
39
- const { store, now, fromStore } = svc.internals
40
- const site = String(siteUrl).replace(/\/+$/, '')
41
- const profile = (a) => ({ id: a.id, name: a.name, email: a.email })
42
- const agentView = (a) => ({ id: a.id, name: a.name, member_id: `agent:${a.id}`, created_at: a.created_at, ...(a.revoked_at ? { revoked_at: a.revoked_at } : {}) })
43
- const accountOf = (me) => {
44
- if (!me.account || me.kind !== 'person') throw new QuiltError(403, 'needs_account', 'sign in with your Seam account for this')
45
- return me.account
46
- }
47
- const wrap = async (fn) => { try { return await fn() } catch (err) { throw fromStore(err) } }
48
-
49
- async function deviceKeyFor (account, publicKey, name) {
50
- const secret = newSecret(DEVICE_KEY_PREFIX)
51
- const at = now()
52
- await wrap(() => store.addKey({ id: uuidv7(at), hash: sha(secret), account_id: account.id, kind: 'device', public_key: publicKey, name, created_at: at, expires_at: at + DEVICE_KEY_DAYS * 86400_000, replace: true }))
53
- return { token: secret, expiresIn: DEVICE_KEY_DAYS * 86400, profile: profile(account) }
54
- }
55
-
56
- /** POST /v1/device/start { publicKey, deviceName, platform? }: a code to approve on the site. */
57
- async function deviceStart ({ publicKey, deviceName, platform } = {}) {
58
- publicKeyOf(publicKey)
59
- const name = String(deviceName || 'a computer').trim().slice(0, 200) || 'a computer'
60
- const code = crypto.randomBytes(32).toString('base64url')
61
- const at = now()
62
- let link
63
- for (let i = 0; !link; i++) {
64
- try { link = await store.addDeviceLink({ id: uuidv7(at), code_hash: sha(code), user_code: userCode(), public_key: String(publicKey), name, platform: platform ? String(platform).slice(0, 40) : null, created_at: at, expires_at: at + LINK_SECONDS * 1000 }) } catch (err) { if (err.code !== 'exists' || i > 3) throw fromStore(err) }
65
- }
66
- return { deviceCode: code, userCode: link.user_code, verificationUrl: `${site}/device?code=${link.user_code}`, expiresIn: LINK_SECONDS, interval: 3 }
67
- }
68
-
69
- async function liveLink (userCodeIn) {
70
- const l = await store.deviceLink({ user_code: String(userCodeIn || '').trim().toUpperCase() })
71
- if (!l || l.expires_at < now()) throw new QuiltError(404, 'not_found', 'no such sign-in code, or it expired: run seam login again')
72
- return l
73
- }
74
-
75
- /** GET /v1/device/:code: what the approval page shows (the signed-in person's own account only). */
76
- async function deviceLinkInfo (me, code) {
77
- accountOf(me)
78
- const l = await liveLink(code)
79
- return { user_code: l.user_code, name: l.name, platform: l.platform, state: l.state, expires_at: l.expires_at }
80
- }
81
-
82
- /** POST /v1/device/approve { user_code, approve? }: the signed-in person approves (or declines) a computer. */
83
- async function deviceApprove (me, { user_code: code, approve = true } = {}) {
84
- const account = accountOf(me)
85
- const l = await liveLink(code)
86
- if (l.state !== 'pending') throw new QuiltError(409, 'moved', 'this code was already used')
87
- const out = await wrap(() => store.setDeviceLink({ id: l.id, from: 'pending', state: approve === false ? 'declined' : 'approved', account_id: account }))
88
- return { user_code: out.user_code, name: out.name, state: out.state }
89
- }
90
-
91
- /** POST /v1/device/poll { deviceCode, signature }: pending, or the computer's key once approved. */
92
- async function devicePoll ({ deviceCode, signature } = {}) {
93
- const l = await store.deviceLink({ code_hash: sha(deviceCode) })
94
- if (!l || !signedBy(l.public_key, `${DEVICE_LINK_CONTEXT}\0${deviceCode}`, signature)) throw new QuiltError(404, 'not_found', 'no such sign-in link')
95
- if (l.state === 'taken' || l.expires_at < now()) throw new QuiltError(410, 'expired', 'this sign-in link expired: run seam login again')
96
- if (l.state === 'declined') throw new QuiltError(403, 'declined', 'sign-in was declined in the browser')
97
- if (l.state === 'pending') return { status: 'pending' }
98
- await wrap(() => store.setDeviceLink({ id: l.id, from: 'approved', state: 'taken' }))
99
- const account = await store.getAccount(l.account_id)
100
- return { status: 'approved', ...(await deviceKeyFor(account, l.public_key, l.name)) }
101
- }
102
-
103
- /** POST /v1/device/resume { publicKey, at, signature }: a linked computer signs back in with its key. */
104
- async function deviceResume ({ publicKey, at, signature } = {}) {
105
- publicKeyOf(publicKey)
106
- if (!Number.isFinite(Number(at)) || Math.abs(now() - Number(at)) > 5 * 60_000) throw new QuiltError(400, 'bad_request', 'at: the time now, in ms (check this computer\'s clock)')
107
- const k = await store.deviceKey(String(publicKey))
108
- if (!k || !signedBy(publicKey, `${DEVICE_RESUME_CONTEXT}\0${at}`, signature)) throw new QuiltError(401, 'not_linked', 'this computer is not signed in: run seam login')
109
- const account = await store.getAccount(k.account_id)
110
- return deviceKeyFor(account, k.public_key, k.name)
111
- }
112
-
113
- /** POST /v1/me/signout: ends the key the caller signed in with. */
114
- async function signout (me) {
115
- if (me.key) await wrap(() => store.revokeKey({ id: me.key, at: now() }))
116
- return { ok: true }
117
- }
118
-
119
- /** The caller a Seam key stands for, or null when it is unknown, revoked or expired. */
120
- async function keyCaller (secret) {
121
- if (!isSeamKey(secret)) return null
122
- const k = await store.keyByHash(sha(secret))
123
- if (!k || k.revoked_at || (k.expires_at && k.expires_at <= now())) return null
124
- const account = await store.getAccount(k.account_id)
125
- if (!account) return null
126
- if (k.kind === 'device') return { member_id: account.member_id, kind: 'person', name: account.name, account: account.id, key: k.id }
127
- const a = await store.getAgent(k.agent_id)
128
- if (!a || a.revoked_at) return null
129
- return { member_id: `agent:${a.id}`, kind: 'agent', name: a.name, tool: 'seam', on_behalf_of: account.member_id, key: k.id }
130
- }
131
-
132
- async function agentKeyFor (account, agent) {
133
- const secret = newSecret(AGENT_KEY_PREFIX)
134
- const at = now()
135
- await wrap(() => store.addKey({ id: uuidv7(at), hash: sha(secret), account_id: account, kind: 'agent', agent_id: agent.id, name: agent.name, created_at: at, expires_at: null, replace: true }))
136
- return { ...agentView(agent), key: secret }
137
- }
138
-
139
- async function ownAgent (account, which) {
140
- const w = String(which || '')
141
- const all = await store.agentsOf(account)
142
- const a = all.find((x) => x.id === w) || (() => {
143
- const named = all.filter((x) => x.name === w && !x.revoked_at)
144
- if (named.length > 1) throw new QuiltError(400, 'bad_request', `${named.length} agents are named ${w}; use its id`)
145
- return named[0]
146
- })()
147
- if (!a) throw new QuiltError(404, 'not_found', `no agent ${w}`)
148
- return a
149
- }
150
-
151
- /** POST /v1/agents { name, claim? }: a new agent and its key (shown in this answer only); `claim` re-links an old agent (agent:<id>) listed for this account's email, keeping its member id and its patches. */
152
- async function createAgent (me, { name, claim } = {}) {
153
- const account = accountOf(me)
154
- if (claim != null && !/^agent:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(claim))) throw new QuiltError(400, 'bad_request', 'claim: the old agent\'s member id, agent:<id>')
155
- let n = String(name || '').trim()
156
- if (!n && claim) n = (await store.agentClaims((await store.getAccount(account)).email)).claims.find((c) => c.member_id === claim)?.name || 'agent'
157
- if (!n || n.length > 100) throw new QuiltError(400, 'bad_request', 'name: the agent\'s name, up to 100 characters')
158
- const at = now()
159
- const a = await wrap(() => store.createAgent({ id: uuidv7(at), account_id: account, name: n, created_at: at, max: MAX_AGENTS, ...(claim ? { claim: String(claim).toLowerCase() } : {}) }))
160
- return agentKeyFor(account, a)
161
- }
162
-
163
- /** GET /v1/agents: the account's agents, revoked ones too, and `relink`: old agents it may re-link. */
164
- async function agents (me) {
165
- const account = accountOf(me)
166
- const a = await store.getAccount(account)
167
- return { agents: (await store.agentsOf(account)).map(agentView), relink: (await store.agentClaims(a.email)).claims }
168
- }
169
-
170
- /** POST /v1/agents/:id/key: a new key for the agent; its old key stops working. */
171
- async function refreshAgentKey (me, which) {
172
- const account = accountOf(me)
173
- const a = await ownAgent(account, which)
174
- if (a.revoked_at) throw new QuiltError(409, 'revoked', `${a.name} was revoked; make a new agent`)
175
- return agentKeyFor(account, a)
176
- }
177
-
178
- /** DELETE /v1/agents/:id: revokes the agent and its key. Its patches stay its own. */
179
- async function revokeAgent (me, which) {
180
- const account = accountOf(me)
181
- const a = await ownAgent(account, which)
182
- return agentView(await wrap(() => store.revokeAgent({ id: a.id, account_id: account, at: now() })))
183
- }
184
-
185
- return { deviceStart, deviceLinkInfo, deviceApprove, devicePoll, deviceResume, signout, keyCaller, createAgent, agents, refreshAgentKey, revokeAgent }
186
- }
@@ -1,67 +0,0 @@
1
- // Per-token rate limits (plans/quilt-source-control/04 "Rate limiting and load shedding",
2
- // decision 40): counted per token, so an agent's key and the person it works for are limited
3
- // apart; a person's token gets five times an agent's numbers (11). A limit answers at once with
4
- // 429 rate_limited, `limit` naming its row and `retry_after` seconds; nothing waits.
5
- // Counted in this API process (sliding windows of request times): with several API machines a
6
- // token can reach up to one allowance per machine (NOT-DONE.md).
7
- import crypto from 'node:crypto'
8
- import { QuiltError } from './service.js'
9
-
10
- const MIN = 60 * 1000
11
- const HOUR = 60 * MIN
12
- const DAY = 24 * HOUR
13
-
14
- /** 04's rows for an agent token: [name, count, window ms, which requests]. */
15
- export const LIMITS = [
16
- ['token_rate', 600, MIN, 'rpc'],
17
- ['commit_rate', 60, MIN, 'commit'],
18
- ['commit_rate', 2000, DAY, 'commit'],
19
- ['merge_rate', 30, HOUR, 'merge']
20
- ]
21
- export const PERSON_FACTOR = 5
22
-
23
- /** Which counters a request takes: object traffic (packs, blob and object reads) takes none. */
24
- export function classify (method, url, body) {
25
- const p = String(url).split('?')[0]
26
- if (/\/(packs|objects|blobs)(\/|$)/.test(p)) return []
27
- if (method === 'POST' && /\/squares\/[^/]+\/commit$/.test(p)) return ['rpc', 'commit']
28
- if (method === 'POST' && /\/lines\/[^/]+\/merge$/.test(p)) return body && body.dry_run ? ['rpc'] : ['rpc', 'merge'] // a dry run is never merge-limited
29
- return ['rpc']
30
- }
31
-
32
- /** The token's key: a hash of its Authorization header (the token itself is never kept). */
33
- export const tokenKey = (req, me) => {
34
- const h = req && req.headers && req.headers.authorization
35
- return h ? crypto.createHash('sha256').update(h).digest('hex').slice(0, 32) : `member:${me.member_id}:${me.kind}`
36
- }
37
-
38
- export function createLimiter ({ now = Date.now, limits = LIMITS, personFactor = PERSON_FACTOR } = {}) {
39
- const seen = new Map() // token + row index → request times, oldest first
40
-
41
- /** Counts one request of `kinds` for `key`, or throws 429 rate_limited without counting it. */
42
- function take (key, kinds, person) {
43
- const t = now()
44
- const rows = []
45
- for (let i = 0; i < limits.length; i++) {
46
- const [name, count, windowMs, kind] = limits[i]
47
- if (!kinds.includes(kind)) continue
48
- const max = person ? count * personFactor : count
49
- const id = `${key}\u0000${i}`
50
- let times = seen.get(id)
51
- if (!times) { times = []; seen.set(id, times) }
52
- let drop = 0
53
- while (drop < times.length && times[drop] <= t - windowMs) drop++
54
- if (drop) times.splice(0, drop)
55
- if (times.length >= max) {
56
- const retry = Math.max(1, Math.ceil((times[0] + windowMs - t) / 1000))
57
- throw new QuiltError(429, 'rate_limited', `rate limit ${name}: ${max} in ${windowMs >= DAY ? 'a day' : windowMs >= HOUR ? 'an hour' : 'a minute'} for this token; try again in ${retry} s`, { limit: name, retry_after: retry })
58
- }
59
- rows.push(times)
60
- }
61
- for (const times of rows) times.push(t)
62
- // Forget tokens with nothing left in any window, so the map stays the size of the active ones.
63
- if (seen.size > 10000) for (const [id, times] of seen) if (!times.length || times[times.length - 1] <= t - DAY) seen.delete(id)
64
- }
65
-
66
- return { take }
67
- }
@@ -1,49 +0,0 @@
1
- // Lines (plans/quilt-source-control/06): main, and release/<name> lines made from any revision.
2
- // A square opens from any line and merges into any line; a frozen line refuses merges with
3
- // line_frozen until it is unfrozen. Anyone who can write makes a line; an admin freezes one.
4
- import { QuiltError } from './service.js'
5
-
6
- const RELEASE = /^release\/[A-Za-z0-9._-]{1,100}$/
7
-
8
- /** `1.0` or `release/1.0` → `release/1.0`; anything else is refused. */
9
- export function lineName (raw) {
10
- const s = String(raw || '').trim()
11
- const name = s === 'main' || s.startsWith('release/') ? s : `release/${s}`
12
- if (name !== 'main' && !RELEASE.test(name)) throw new QuiltError(400, 'bad_request', 'a new line is release/<name>: letters, digits, dots, dashes and underscores')
13
- return name
14
- }
15
-
16
- export function lineOps (svc) {
17
- const { store, now, quiltFor, resolve, fromStore } = svc.internals
18
-
19
- const view = (l) => ({ name: l.name, tip: l.tip, patch_count: l.patch_count, frozen: !!l.frozen })
20
-
21
- /** GET /lines: every line of the quilt, main first. */
22
- async function lines (me, ref) {
23
- const q = await quiltFor(me, ref, 'read')
24
- const all = (await store.lines(q.id)).map(view)
25
- return { lines: [...all.filter((l) => l.name === 'main'), ...all.filter((l) => l.name !== 'main')] }
26
- }
27
-
28
- /** POST /lines { name, from? }: a new line at a revision (default the tip of main). */
29
- async function newLine (me, ref, { name, from } = {}) {
30
- const q = await quiltFor(me, ref, 'write')
31
- const n = lineName(name)
32
- if (n === 'main') throw new QuiltError(409, 'line_exists', 'there is already a line main')
33
- const tip = await resolve(q.id, from || 'main')
34
- if (!tip) throw new QuiltError(400, 'bad_request', `${from || 'main'} has no commits to start a line from`)
35
- try { return view(await store.createLine({ quilt_id: q.id, name: n, tip, actor: me.member_id, at: now() })) } catch (err) { throw fromStore(err) }
36
- }
37
-
38
- async function setFrozen (me, ref, line, frozen) {
39
- const q = await quiltFor(me, ref, 'admin')
40
- try { return view(await store.setLineFrozen({ quilt_id: q.id, name: String(line), frozen, actor: me.member_id, at: now() })) } catch (err) { throw fromStore(err) }
41
- }
42
-
43
- return {
44
- lines,
45
- newLine,
46
- freezeLine: (me, ref, line) => setFrozen(me, ref, line, true),
47
- unfreezeLine: (me, ref, line) => setFrozen(me, ref, line, false)
48
- }
49
- }
@@ -1,50 +0,0 @@
1
- // A square's live work (02, sq:<id>:live): the uncommitted changes its holder shares on the server,
2
- // so others (people, agents, CI) can read work in progress before it is committed. It is whole
3
- // files (each put or delete replaces what the square's head has), replaced at once by each PUT,
4
- // and never history: a commit is still how work is kept. Any host can feed it, a block folder
5
- // (`seam live share`), an editor or a live-editing service, through the same route.
6
- import { QuiltError, squareShort } from './service.js'
7
- import { guardClaims } from './claims.js'
8
-
9
- export function liveOps (svc) {
10
- const { store, now, quiltFor, squareByRef, writable, prepareChanges, seal, snapshot, reader, fromStore } = svc.internals
11
-
12
- // Only what still differs from the square's head: a change committed since drops out.
13
- async function view (q, sq, row) {
14
- const head = sq.head || sq.base
15
- const root = await snapshot(q, head)
16
- const changes = []
17
- for (const c of row?.changes || []) {
18
- const e = await reader(q).lookup(root, c.path)
19
- if (c.op === 'delete' ? !e : (e && e.blob === c.blob && e.mode === c.mode)) continue
20
- changes.push({ path: c.path, op: c.op, ...(c.op === 'put' ? { blob: c.blob, mode: c.mode, size: c.size, status: e ? 'modified' : 'added' } : { status: 'deleted' }) })
21
- }
22
- return { square: squareShort(sq.id), square_id: sq.id, head, made_on: row?.head || null, changes, by: row?.actor || null, at: row?.at || null }
23
- }
24
-
25
- /** GET /squares/:id/live: the square's shared uncommitted changes. */
26
- async function live (me, ref, sqRef) {
27
- const q = await quiltFor(me, ref, 'read')
28
- const sq = await squareByRef(q.id, sqRef)
29
- return view(q.id, sq, await store.getLive(q.id, sq.id))
30
- }
31
-
32
- /** PUT /squares/:id/live { changes: [{ path, op: 'put', content (base64) | blob, mode? } | { path, op: 'delete' }] }: replaces it; [] clears. */
33
- async function setLive (me, ref, sqRef, body = {}) {
34
- const q = await quiltFor(me, ref, 'write')
35
- const sq = await writable(me, q, sqRef)
36
- if (sq.state !== 'open') throw new QuiltError(409, 'square_closed', `the square is ${sq.state}`)
37
- if (!Array.isArray(body.changes || [])) throw new QuiltError(400, 'bad_request', 'changes is a list')
38
- if ((body.changes || []).length > 5000) throw new QuiltError(400, 'bad_request', 'up to 5000 changes')
39
- const prep = await prepareChanges(q.id, sq, body.changes || [])
40
- await guardClaims(store, q.id, sq, prep.changes.map((x) => x.path))
41
- if (prep.blobs.length) await seal(q.id, prep.blobs, { by: me.member_id })
42
- const changes = prep.changes.map(({ copy_from: _, ...c }) => c)
43
- try {
44
- const row = await store.setLive({ quilt_id: q.id, square_id: sq.id, head: sq.head || sq.base, changes, actor: me.member_id, at: now() })
45
- return view(q.id, sq, row)
46
- } catch (err) { throw fromStore(err) }
47
- }
48
-
49
- return { live, setLive }
50
- }
@@ -1,114 +0,0 @@
1
- // The merge ladder seam settles a clash with (plans/quilt-source-control/07, steps 1 and 2): line
2
- // by line as git does, then two inserts at one place, a one-line import both sides changed,
3
- // whitespace-only changes, and word by word within the clashing lines. Seam's own copy, so the
4
- // Quilt app's merges can change without changing how a quilt merges.
5
- import { diff3Merge } from 'node-diff3'
6
-
7
- const lines = (s) => s.split('\n')
8
-
9
- /**
10
- * A text's line endings: 'crlf' (every line ends \r\n), 'lf' (none does), 'mixed', or null (one line:
11
- * nothing to tell).
12
- */
13
- function eolOf (s) {
14
- if (typeof s !== 'string' || !s.includes('\n')) return null
15
- if (!s.includes('\r\n')) return 'lf'
16
- return s.replace(/\r\n/g, '').includes('\n') ? 'mixed' : 'crlf'
17
- }
18
-
19
- /**
20
- * The merges compare lines, so a side saved with Windows line endings (\r\n) against a base and other
21
- * side with \n differs on every line: diff3 then reads it as "ours deleted the whole file and inserted
22
- * it again", the delete loses to theirs' edits and the insert stays, and the file comes out as both
23
- * versions one after the other (task 7b480c8b07c4fa02: web/lib/docs.js, CRLF old + LF new, every
24
- * export twice). When the sides' line endings differ, all three are merged with \n and the result is
25
- * written in the line endings of the side that changed them from the base (ours when both did, or
26
- * when there is no base to tell), as any other edit to the file would be kept.
27
- */
28
- function eolAware (merge) {
29
- return function (base, ours, theirs, ...rest) {
30
- const eols = [base, ours, theirs].map(eolOf)
31
- const seen = new Set(eols.filter(Boolean))
32
- if (!seen.has('crlf') || seen.size < 2) return merge(base, ours, theirs, ...rest)
33
- const [eb, eo, et] = eols
34
- const target = (eo && eo !== eb) ? eo : (et && et !== eb) ? et : (eo || et || eb)
35
- const lf = (s) => typeof s === 'string' ? s.replace(/\r\n/g, '\n') : s
36
- const back = (t) => target === 'crlf' && typeof t === 'string' ? t.replace(/\n/g, '\r\n') : t
37
- const r = merge(lf(base), lf(ours), lf(theirs), ...rest)
38
- return typeof r === 'string' ? back(r) : { ...r, text: back(r.text) }
39
- }
40
- }
41
-
42
- /** Two blocks inserted at one place, as one: shared leading and trailing lines once, the middles in order. */
43
- function addedBoth (a, b) {
44
- let pre = 0
45
- while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
46
- let post = 0
47
- while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post]) post++
48
- return [...a.slice(0, pre), ...a.slice(pre, a.length - post), ...b.slice(pre, b.length - post), ...a.slice(a.length - post)]
49
- }
50
-
51
- const IMPORT = /^(\s*import\s*\{)([^}]*)(\}\s*from\s*(['"][^'"]+['"]).*)$/
52
-
53
- /** Both sides changed the same one-line import of the same module: the union of its names, or null. */
54
- function sameImport (o, a, b) {
55
- if (o.length !== 1 || a.length !== 1 || b.length !== 1) return null
56
- const [mo, ma, mb] = [IMPORT.exec(o[0]), IMPORT.exec(a[0]), IMPORT.exec(b[0])]
57
- if (!mo || !ma || !mb || mo[4] !== ma[4] || ma[4] !== mb[4]) return null
58
- const names = (m) => m[2].split(',').map((s) => s.trim()).filter(Boolean)
59
- const [no, na, nb] = [names(mo), names(ma), names(mb)]
60
- const gone = new Set(no.filter((n) => !na.includes(n) || !nb.includes(n)))
61
- const all = [...na, ...nb.filter((n) => !na.includes(n))].filter((n) => !gone.has(n))
62
- return [`${ma[1]} ${all.join(', ')} ${ma[3]}`]
63
- }
64
-
65
- const normWs = (l) => l.replace(/\s+/g, ' ').trim()
66
- const sameIgnoringWs = (a, b) => a.length === b.length && a.every((l, i) => normWs(l) === normWs(b[i]))
67
- const TOKEN = /\s+|\w+|[^\w\s]/g
68
-
69
- /** Two blocks of lines merged word by word against their base, or null when the same words changed on both sides. */
70
- function mergeWords (o, a, b) {
71
- const tok = (ls) => ls.join('\n').match(TOKEN) || []
72
- const regions = diff3Merge(tok(a), tok(o), tok(b), { excludeFalseConflicts: true })
73
- if (regions.some((r) => !r.ok)) return null
74
- return regions.map((r) => r.ok.join('')).join('').split('\n')
75
- }
76
-
77
- /**
78
- * The clash ladder's mechanical and structural steps (plan §7.3, steps 1 and 2): mergeAdditive, then
79
- * each region still clashing is tried again ignoring whitespace (a side that only reindented loses
80
- * to the side that changed something), then word by word within the clashing lines (two edits to
81
- * different words of one line both land); a file marked merge=union takes both sides, ours first.
82
- * Returns { text, conflicts, how } with `how` the step that settled it: 'lines', 'whitespace',
83
- * 'words' or 'union'; `conflicts` lists what no step could settle (text then holds ours there).
84
- */
85
- export const mergeSmart = eolAware(function mergeSmart (base, ours, theirs, { union = false } = {}) {
86
- const regions = diff3Merge(lines(ours), lines(base), lines(theirs), { excludeFalseConflicts: true })
87
- const out = []
88
- const conflicts = []
89
- let how = 'lines'
90
- const rank = { lines: 0, whitespace: 1, words: 2, union: 3 }
91
- const step = (h) => { if (rank[h] > rank[how]) how = h }
92
- for (const r of regions) {
93
- if (r.ok) { out.push(...r.ok); continue }
94
- const c = r.conflict
95
- const settled = ladder(c, { union })
96
- if (settled) { out.push(...settled.lines); step(settled.how); continue }
97
- conflicts.push({ base: c.o, ours: c.a, theirs: c.b })
98
- out.push(...c.a)
99
- }
100
- return { text: out.join('\n'), conflicts, how }
101
- })
102
-
103
- /** mergeSmart's steps for one region both sides changed ({ o, a, b }): { lines, how }, or null when none settles it. */
104
- function ladder (c, { union = false } = {}) {
105
- if (union) return { lines: [...c.a, ...c.b], how: 'union' }
106
- const both = c.o.length === 0 ? addedBoth(c.a, c.b) : sameImport(c.o, c.a, c.b)
107
- if (both) return { lines: both, how: 'lines' }
108
- if (sameIgnoringWs(c.a, c.b)) return { lines: c.a, how: 'whitespace' } // the same change, spaced differently
109
- if (sameIgnoringWs(c.a, c.o)) return { lines: c.b, how: 'whitespace' } // ours only reindented
110
- if (sameIgnoringWs(c.b, c.o)) return { lines: c.a, how: 'whitespace' } // theirs only reindented
111
- const words = mergeWords(c.o, c.a, c.b)
112
- if (words) return { lines: words, how: 'words' }
113
- return null
114
- }