heyquilt 0.3.121 → 0.3.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/RELEASES.md +292 -17
  2. package/bin/quilt.js +72 -5
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -1
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +21 -10
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/seam-connect.js +103 -0
  15. package/src/api/routes/session-migrate.js +39 -0
  16. package/src/api/routes/sessions.js +73 -1
  17. package/src/api/server.js +46 -12
  18. package/src/api/supabase-store.js +53 -4
  19. package/src/autowork.js +182 -16
  20. package/src/connection.js +39 -9
  21. package/src/control.js +139 -13
  22. package/src/force-date.js +27 -0
  23. package/src/fsutil.js +13 -3
  24. package/src/gitstate.js +38 -2
  25. package/src/history.js +25 -1
  26. package/src/mcp.js +93 -7
  27. package/src/merge3.js +11 -3
  28. package/src/now-summary.js +113 -0
  29. package/src/pathrules.js +12 -0
  30. package/src/protocol.js +10 -2
  31. package/src/quilt-default.js +84 -0
  32. package/src/quilt-history.js +106 -0
  33. package/src/quilt-remote.js +78 -0
  34. package/src/relay-commit.js +5 -0
  35. package/src/relay-lease.js +6 -2
  36. package/src/relay-mcp.js +172 -8
  37. package/src/relay-outbound.js +1 -1
  38. package/src/relay-prune.js +4 -1
  39. package/src/release-words.js +2 -2
  40. package/src/runner.js +20 -7
  41. package/src/seam-access.js +97 -0
  42. package/src/seam-claims.js +53 -0
  43. package/src/seam-url.js +15 -0
  44. package/src/server.js +1767 -33
  45. package/src/session-follow.js +515 -0
  46. package/src/session-layout3.js +466 -0
  47. package/src/session-migrate.js +123 -0
  48. package/src/session-quilt.js +145 -0
  49. package/src/session-seam.js +284 -0
  50. package/src/session.js +899 -46
  51. package/src/source-live/bring-in.js +283 -0
  52. package/src/source-live/live-block.js +507 -35
  53. package/src/source-live/live-square.js +17 -6
  54. package/src/status.js +5 -0
  55. package/src/ui/agent-guide.js +1 -1
  56. package/src/ui/app.css +85 -20
  57. package/src/ui/app.js +4 -1
  58. package/src/ui/chat.js +9 -3
  59. package/src/ui/cloud-ai-model.js +13 -0
  60. package/src/ui/cloud-ai.js +10 -2
  61. package/src/ui/common.js +6 -2
  62. package/src/ui/fileview.js +20 -7
  63. package/src/ui/force-words.js +19 -0
  64. package/src/ui/git-rule.js +9 -0
  65. package/src/ui/home.js +0 -147
  66. package/src/ui/loom-model.js +15 -1
  67. package/src/ui/quiltview.js +213 -0
  68. package/src/ui/seam-model.js +251 -0
  69. package/src/ui/session.js +413 -31
  70. package/src/ui/voice-call.js +83 -14
  71. package/src/ui/voice-model.js +39 -0
  72. package/src/ui/voice.js +167 -17
  73. package/src/ui-server.js +80 -28
  74. package/src/update-check.js +25 -1
  75. package/src/voice.js +13 -6
  76. package/src/git.js +0 -147
  77. package/src/quilts/AGENTS.md +0 -1
  78. package/src/quilts/CLAUDE.md +0 -1
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/claims.js +0 -144
  87. package/src/quilts/cli.js +0 -449
  88. package/src/quilts/client.js +0 -128
  89. package/src/quilts/conflict-format.js +0 -190
  90. package/src/quilts/describe.js +0 -266
  91. package/src/quilts/dogfood.js +0 -82
  92. package/src/quilts/errors.js +0 -8
  93. package/src/quilts/events.js +0 -160
  94. package/src/quilts/export-git.js +0 -130
  95. package/src/quilts/export.js +0 -81
  96. package/src/quilts/fastmerge.js +0 -41
  97. package/src/quilts/hash.js +0 -44
  98. package/src/quilts/history.js +0 -266
  99. package/src/quilts/ids.js +0 -12
  100. package/src/quilts/import-git.js +0 -291
  101. package/src/quilts/import.js +0 -134
  102. package/src/quilts/index.js +0 -72
  103. package/src/quilts/limits.js +0 -67
  104. package/src/quilts/lines.js +0 -49
  105. package/src/quilts/live.js +0 -50
  106. package/src/quilts/merge-smart.js +0 -114
  107. package/src/quilts/merge.js +0 -486
  108. package/src/quilts/objects.js +0 -144
  109. package/src/quilts/pack.js +0 -118
  110. package/src/quilts/records.js +0 -211
  111. package/src/quilts/resolutions.js +0 -69
  112. package/src/quilts/reviews.js +0 -195
  113. package/src/quilts/routes.js +0 -126
  114. package/src/quilts/service.js +0 -923
  115. package/src/quilts/setup.js +0 -34
  116. package/src/quilts/signing.js +0 -149
  117. package/src/quilts/squares.js +0 -239
  118. package/src/quilts/store-memory.js +0 -683
  119. package/src/quilts/store-rpc.js +0 -79
  120. package/src/quilts/store-supabase.js +0 -13
  121. package/src/quilts/tags.js +0 -64
  122. package/src/quilts/textdiff.js +0 -96
  123. package/src/quilts/tokens.js +0 -66
  124. package/src/quilts/tools.js +0 -375
  125. package/src/quilts/tree.js +0 -351
  126. package/src/quilts/webhook.js +0 -116
  127. package/src/quilts/window.js +0 -119
  128. package/src/quilts/xdiff.js +0 -435
  129. package/src/ui/branches.js +0 -111
@@ -1,351 +0,0 @@
1
- // Snapshots as prolly trees (plans/quilt-source-control/03, "Prolly-tree snapshots"): the full
2
- // tree of a commit, over the quilt's sorted path list. Node boundaries come from a hash of the
3
- // key bytes only, so the shape depends on the paths and nothing else: two commits with the same
4
- // files have the same root, and one changed file rewrites a handful of nodes.
5
- //
6
- // Leaf node: { l: 0, e: [[path, mode, blob, size] | [path, 'conflict', term, 0]] }
7
- // Internal node: { l: level, e: [[first_key, child_id]] }
8
- // Node id: blake3("quilt tree v1", canonical CBOR of the node).
9
- import { encode, decode } from './cbor.js'
10
- import { idOf } from './hash.js'
11
- import { cmpPath, under } from './records.js'
12
-
13
- // Boundaries: after at least MIN entries, a node ends after an entry whose key hash has its low
14
- // six bits set (a mean near 64 entries, about 4 KiB of leaf); never more than MAX entries.
15
- const MIN = 8
16
- const MAX = 256
17
- const MASK = 63
18
-
19
- const hex = (b) => Buffer.from(b).toString('hex')
20
-
21
- /** FNV-1a over the key's bytes and the level, the boundary hash ("quilt tree v1"). */
22
- function keyHash (key, level) {
23
- let h = 0x811c9dc5 ^ level
24
- const b = Buffer.from(key)
25
- for (let i = 0; i < b.length; i++) { h ^= b[i]; h = Math.imul(h, 0x01000193) >>> 0 }
26
- return h >>> 0
27
- }
28
-
29
- const leafRow = (e) => e.mode === 'conflict'
30
- ? [e.path, 'conflict', Buffer.from(e.term, 'hex'), 0]
31
- : [e.path, e.mode, Buffer.from(e.blob, 'hex'), e.size]
32
- const unleaf = ([path, mode, id, size]) => mode === 'conflict'
33
- ? { path, mode: 'conflict', term: hex(id) }
34
- : { path, mode, blob: hex(id), size }
35
-
36
- /** A node's canonical bytes and id. */
37
- function nodeOf (level, rows) {
38
- const bytes = encode({ l: level, e: rows })
39
- return { id: idOf('tree', bytes), bytes }
40
- }
41
-
42
- /** Decodes a tree node: { level, entries } (leaf entries, or [{ key, child }]). */
43
- export function decodeNode (bytes) {
44
- const n = decode(bytes)
45
- if (n.l === 0) return { level: 0, entries: n.e.map(unleaf) }
46
- return { level: n.l, entries: n.e.map(([key, child]) => ({ key, child: hex(child) })) }
47
- }
48
-
49
- function chunk (items, keyOf, level) {
50
- const groups = []
51
- let cur = []
52
- for (const it of items) {
53
- cur.push(it)
54
- const k = keyOf(it)
55
- if (cur.length >= MAX || (cur.length >= MIN && (keyHash(k, level) & MASK) === MASK)) { groups.push(cur); cur = [] }
56
- }
57
- if (cur.length || !groups.length) groups.push(cur)
58
- return groups
59
- }
60
-
61
- /**
62
- * Builds the tree of `entries` (sorted by path, unique). Returns { root, nodes } where nodes is a
63
- * Map of id → bytes for every node of the tree (callers store the ones they don't have).
64
- */
65
- export function buildTree (entries) {
66
- const nodes = new Map()
67
- let level = 0
68
- let items = chunk(entries, (e) => e.path, 0).map((g) => {
69
- const n = nodeOf(0, g.map(leafRow))
70
- nodes.set(n.id, n.bytes)
71
- return { key: g.length ? g[0].path : '', child: n.id }
72
- })
73
- while (items.length > 1) {
74
- level++
75
- items = chunk(items, (it) => it.key, level).map((g) => {
76
- const n = nodeOf(level, g.map((it) => [it.key, Buffer.from(it.child, 'hex')]))
77
- nodes.set(n.id, n.bytes)
78
- return { key: g[0].key, child: n.id }
79
- })
80
- }
81
- return { root: items[0].child, nodes }
82
- }
83
-
84
- /** The empty tree: never stored, known to every reader. */
85
- const empty = buildTree([])
86
- export const EMPTY_TREE = empty.root
87
- export const EMPTY_TREE_BYTES = empty.nodes.get(empty.root)
88
-
89
- /** Applies changes ({ path, op, blob, mode, size, term }) to sorted entries; returns new sorted entries. */
90
- export function applyChanges (entries, changes) {
91
- const map = new Map(entries.map((e) => [e.path, e]))
92
- for (const c of changes) {
93
- if (c.op === 'delete') map.delete(c.path)
94
- else if (c.op === 'conflict') map.set(c.path, { path: c.path, mode: 'conflict', term: c.term })
95
- else map.set(c.path, { path: c.path, mode: c.mode, blob: c.blob, size: c.size })
96
- }
97
- return [...map.values()].sort((a, b) => cmpPath(a.path, b.path))
98
- }
99
-
100
- /**
101
- * Reads trees through `getNode(id) → bytes` (async; callers cache). Every read is a walk from a
102
- * root, skipping the subtrees it does not need.
103
- */
104
- export class TreeReader {
105
- constructor (getNode) {
106
- this.getNode = getNode
107
- this.cache = new Map()
108
- }
109
-
110
- async node (id) {
111
- let n = this.cache.get(id)
112
- if (!n) {
113
- n = decodeNode(id === EMPTY_TREE ? EMPTY_TREE_BYTES : await this.getNode(id))
114
- this.cache.set(id, n)
115
- if (this.cache.size > 20000) this.cache.delete(this.cache.keys().next().value)
116
- }
117
- return n
118
- }
119
-
120
- /** Every entry of the tree, in order. */
121
- async entries (root) { return this.list(root, ['']) }
122
-
123
- /** The entry at `path`, or null. */
124
- async lookup (root, path) {
125
- const found = await this.list(root, [path], { exact: true })
126
- return found.find((e) => e.path === path) || null
127
- }
128
-
129
- /**
130
- * The entries under any of `prefixes` (directories; '' is the root), in order. Only the nodes
131
- * whose key range meets a prefix are read.
132
- */
133
- async list (root, prefixes, { exact = false } = {}) {
134
- const ranges = prefixes.map((p) => p ? (exact ? [[p, p + '\0']] : [[p, p + '\0'], [p + '/', p + '0']]) : [['', null]]).flat()
135
- const meets = (lo, hi) => ranges.some(([a, b]) => (b === null || cmpPath(lo, b) < 0) && (hi === null || cmpPath(hi, a) > 0))
136
- const out = []
137
- const walk = async (id, lo, hi) => {
138
- const n = await this.node(id)
139
- if (n.level === 0) {
140
- for (const e of n.entries) if (prefixes.some((p) => exact ? e.path === p : under(e.path, p))) out.push(e)
141
- return
142
- }
143
- for (let i = 0; i < n.entries.length; i++) {
144
- const clo = i === 0 ? lo : n.entries[i].key
145
- const chi = i + 1 < n.entries.length ? n.entries[i + 1].key : hi
146
- if (meets(clo, chi)) await walk(n.entries[i].child, clo, chi)
147
- }
148
- }
149
- await walk(root, '', null)
150
- return out
151
- }
152
-
153
- /**
154
- * What changed from tree `a` to tree `b`: [{ path, before?, after? }] in path order. Subtrees
155
- * with the same id on both sides are never read, so the cost follows the change, not the tree.
156
- */
157
- async diff (a, b) {
158
- if (a === b) return []
159
- let fa = new Set([a])
160
- let fb = new Set([b])
161
- for (;;) {
162
- for (const id of [...fa]) if (fb.has(id)) { fa.delete(id); fb.delete(id) }
163
- const expand = async (front) => {
164
- const next = new Set()
165
- let grew = false
166
- for (const id of front) {
167
- const n = await this.node(id)
168
- if (n.level === 0) next.add(id)
169
- else { grew = true; for (const e of n.entries) next.add(e.child) }
170
- }
171
- return { next, grew }
172
- }
173
- const [ea, eb] = [await expand(fa), await expand(fb)]
174
- fa = ea.next; fb = eb.next
175
- if (!ea.grew && !eb.grew) break
176
- }
177
- for (const id of [...fa]) if (fb.has(id)) { fa.delete(id); fb.delete(id) }
178
- const leaves = async (front) => {
179
- const m = new Map()
180
- for (const id of front) for (const e of (await this.node(id)).entries) m.set(e.path, e)
181
- return m
182
- }
183
- const [ma, mb] = [await leaves(fa), await leaves(fb)]
184
- const paths = [...new Set([...ma.keys(), ...mb.keys()])].sort(cmpPath)
185
- const out = []
186
- for (const p of paths) {
187
- const x = ma.get(p)
188
- const y = mb.get(p)
189
- if (x && y && sameEntry(x, y)) continue
190
- out.push({ path: p, ...(x ? { before: x } : {}), ...(y ? { after: y } : {}) })
191
- }
192
- return out
193
- }
194
- }
195
-
196
- export const sameEntry = (x, y) => x.mode === y.mode && x.blob === y.blob && x.term === y.term
197
-
198
- const itemKey = (level) => level === 0 ? (e) => e.path : (it) => it.key
199
- const nodeRows = (level, items) => level === 0 ? items.map(leafRow) : items.map((it) => [it.key, Buffer.from(it.child, 'hex')])
200
-
201
- /**
202
- * A cursor over the node references of one level of the tree at `root` (top level `top`),
203
- * placed on the node whose subtree holds `key`. It reads only the nodes on its way down and
204
- * the neighbours it is moved onto: never a whole level.
205
- */
206
- async function cursorAt (reader, root, top, level, key) {
207
- const stack = []
208
- let id = root
209
- for (let l = top; l > level; l--) {
210
- const es = (await reader.node(id)).entries
211
- let lo = 0; let hi = es.length - 1
212
- while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (cmpPath(es[mid].key, key) <= 0) lo = mid; else hi = mid - 1 }
213
- stack.push({ es, i: lo })
214
- id = es[lo].child
215
- }
216
- let end = false
217
- return {
218
- get end () { return end },
219
- ref: () => end ? null : stack.length ? stack[stack.length - 1].es[stack[stack.length - 1].i] : { key: '', child: root },
220
- first: () => stack.every((x) => x.i === 0),
221
- async next () {
222
- let d = stack.length - 1
223
- while (d >= 0 && stack[d].i + 1 >= stack[d].es.length) d--
224
- if (d < 0) { end = true; return }
225
- stack[d].i++
226
- for (let j = d + 1; j < stack.length; j++) stack[j] = { es: (await reader.node(stack[j - 1].es[stack[j - 1].i].child)).entries, i: 0 }
227
- }
228
- }
229
- }
230
-
231
- /**
232
- * Re-chunks one level where it was edited. `edits` ([{ from, to, items }], in order) replace the
233
- * items of the old nodes from..to. Chunking restarts at an edit's first node (its start is a
234
- * boundary in the old tree, so the chunker's state is the same) and stops once a new boundary
235
- * falls on an old node's end with no edit next: from there the old nodes are the new ones.
236
- * Returns splices [{ from, to, refs }]: old nodes from..to became `refs`.
237
- */
238
- async function rechunkLevel (reader, root, top, level, edits, nodes) {
239
- const keyOf = itemKey(level)
240
- const splices = []
241
- let e = 0
242
- while (e < edits.length) {
243
- const cur = await cursorAt(reader, root, top, level, edits[e].from.key)
244
- const atFirst = cur.first()
245
- const sp = { from: cur.ref(), to: null, refs: [] }
246
- let buf = []
247
- const emit = () => {
248
- const n = nodeOf(level, nodeRows(level, buf))
249
- nodes.set(n.id, n.bytes)
250
- sp.refs.push({ key: buf.length ? keyOf(buf[0]) : '', child: n.id })
251
- buf = []
252
- }
253
- let synced = false
254
- for (;;) {
255
- let items
256
- if (e < edits.length && cur.ref().child === edits[e].from.child) {
257
- items = edits[e].items
258
- while (cur.ref().child !== edits[e].to.child) await cur.next()
259
- e++
260
- } else items = (await reader.node(cur.ref().child)).entries
261
- sp.to = cur.ref()
262
- let atEnd = false
263
- for (let k = 0; k < items.length; k++) {
264
- buf.push(items[k])
265
- if (buf.length >= MAX || (buf.length >= MIN && (keyHash(keyOf(items[k]), level) & MASK) === MASK)) { emit(); atEnd = k === items.length - 1 }
266
- }
267
- await cur.next()
268
- if (cur.end) break
269
- if (atEnd && !(e < edits.length && cur.ref().child === edits[e].from.child)) { synced = true; break }
270
- }
271
- if (!synced && (buf.length || (atFirst && !sp.refs.length))) emit()
272
- splices.push(sp)
273
- }
274
- return splices
275
- }
276
-
277
- /** The level above's edits for `splices`: each parent holding a splice gets its new child list. */
278
- async function parentEdits (reader, root, top, level, splices) {
279
- const out = []
280
- for (const sp of splices) {
281
- const pF = (await cursorAt(reader, root, top, level, sp.from.key)).ref()
282
- const pL = (await cursorAt(reader, root, top, level, sp.to.key)).ref()
283
- const last = out[out.length - 1]
284
- if (last && last.to.child === pF.child) { last.to = pL; last.splices.push(sp) } else out.push({ from: pF, to: pL, splices: [sp] })
285
- }
286
- for (const ed of out) {
287
- const c = await cursorAt(reader, root, top, level, ed.from.key)
288
- const items = []
289
- let s = 0
290
- let skipTo = null
291
- for (;;) {
292
- for (const ch of (await reader.node(c.ref().child)).entries) {
293
- if (skipTo) { if (ch.child === skipTo) skipTo = null; continue }
294
- const sp = ed.splices[s]
295
- if (sp && ch.child === sp.from.child) { items.push(...sp.refs); if (ch.child !== sp.to.child) skipTo = sp.to.child; s++; continue }
296
- items.push(ch)
297
- }
298
- if (c.ref().child === ed.to.child) break
299
- await c.next()
300
- }
301
- ed.items = items
302
- }
303
- return out
304
- }
305
-
306
- /**
307
- * The tree `root` with `changes` applied ({ path, op: put|delete|conflict, ... }), equal to
308
- * `buildTree` of the changed entries but reading and writing only the nodes on the changed
309
- * paths and the neighbours the chunker reads until it is back in step: O(changes × depth).
310
- * Returns { root, nodes } (the new nodes).
311
- */
312
- export async function updateTree (reader, root, changes) {
313
- const nodes = new Map()
314
- if (!changes.length) return { root, nodes }
315
- const top = (await reader.node(root)).level
316
- // Level 0: the changes, grouped by the leaf they fall in.
317
- let edits = []
318
- for (const c of [...changes].sort((a, b) => cmpPath(a.path, b.path))) {
319
- const ref = (await cursorAt(reader, root, top, 0, c.path)).ref()
320
- const last = edits[edits.length - 1]
321
- if (last && last.from.child === ref.child) last.changes.push(c)
322
- else edits.push({ from: ref, to: ref, changes: [c] })
323
- }
324
- for (const ed of edits) ed.items = applyChanges((await reader.node(ed.from.child)).entries, ed.changes)
325
- let refs
326
- for (let l = 0; ; l++) {
327
- const splices = await rechunkLevel(reader, root, top, l, edits, nodes)
328
- if (l === top) { refs = splices[0].refs; break }
329
- edits = await parentEdits(reader, root, top, l + 1, splices)
330
- }
331
- // The tree grew: chunk the levels above from memory, as buildTree does.
332
- let level = top
333
- while (refs.length > 1) {
334
- level++
335
- refs = chunk(refs, (it) => it.key, level).map((g) => {
336
- const n = nodeOf(level, nodeRows(level, g))
337
- nodes.set(n.id, n.bytes)
338
- return { key: g[0].key, child: n.id }
339
- })
340
- }
341
- // The tree shrank: a root with one child is that child (buildTree stops at a one-node level).
342
- let r = refs[0].child
343
- for (;;) {
344
- const n = nodes.has(r) ? decodeNode(nodes.get(r)) : await reader.node(r)
345
- if (n.level === 0 || n.entries.length !== 1) break
346
- nodes.delete(r)
347
- r = n.entries[0].child
348
- }
349
- if (r === EMPTY_TREE) nodes.delete(r)
350
- return { root: r, nodes }
351
- }
@@ -1,116 +0,0 @@
1
- // Seam's own webhooks: a member's subscription and its signed POSTs. Each POST carries
2
- // x-quilt-event, x-quilt-delivery, x-quilt-timestamp and
3
- // x-quilt-signature: sha256=HMAC-SHA256(secret, "<timestamp>.<body>"), and is retried after
4
- // each of RETRY_DELAYS_MS when the receiver is down, slow, or answers 408, 429 or 5xx.
5
- import crypto from 'node:crypto'
6
- import net from 'node:net'
7
-
8
- export const RETRY_DELAYS_MS = [1000, 5000, 25_000]
9
- export const DELIVERY_TIMEOUT_MS = 10_000
10
- export const MAX_URL = 2000
11
- export const MIN_SECRET = 16
12
- export const MAX_SECRET = 200
13
- export const MAX_BEARER = 500
14
-
15
- const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0'])
16
- const PRIVATE_V4 = new net.BlockList()
17
- for (const [a, p] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 3]]) PRIVATE_V4.addSubnet(a, p, 'ipv4')
18
- const PRIVATE_V6 = new net.BlockList()
19
- for (const [a, p] of [['::', 96], ['64:ff9b::', 96], ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8]]) PRIVATE_V6.addSubnet(a, p, 'ipv6')
20
-
21
- /** Whether a host as written in a URL is this computer or a private network address. */
22
- function isLocalHost (host) {
23
- const h = host.toLowerCase().replace(/^\[|\]$/g, '')
24
- if (LOCAL_HOSTS.has(h) || h.endsWith('.localhost')) return true
25
- const mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)
26
- const a = mapped ? mapped[1] : h
27
- const kind = net.isIP(a)
28
- if (kind === 4) return PRIVATE_V4.check(a, 'ipv4')
29
- if (kind === 6) return PRIVATE_V6.check(a, 'ipv6')
30
- return false
31
- }
32
-
33
- /** The URL a subscription may POST to: https, no credentials, public (allowLocal also takes http to this computer). */
34
- export function parseWebhookUrl (raw, { allowLocal = false } = {}) {
35
- const s = String(raw || '').trim()
36
- if (!s) throw new Error('give the URL seam should POST to')
37
- if (s.length > MAX_URL) throw new Error('that URL is too long')
38
- let u
39
- try { u = new URL(s) } catch { throw new Error('that is not a valid URL') }
40
- if (u.username || u.password) throw new Error('the URL may not carry a user name or password')
41
- const local = isLocalHost(u.hostname)
42
- if (u.protocol === 'http:') {
43
- if (!allowLocal || !local) throw new Error('the URL must use https')
44
- } else if (u.protocol !== 'https:') {
45
- throw new Error('the URL must start with https://')
46
- } else if (local && !allowLocal) {
47
- throw new Error('the URL must be reachable from the internet, not a local or private address')
48
- }
49
- return u.toString()
50
- }
51
-
52
- /** { url, secret?, bearer? } → { url, secret, since, made, bearer? }: a secret left out is made (and shown once). */
53
- export function makeSubscription ({ url, secret, bearer } = {}, { allowLocal = false, now = Date.now } = {}) {
54
- const u = parseWebhookUrl(url, { allowLocal })
55
- let s = secret == null ? '' : String(secret)
56
- if (s && (s.length < MIN_SECRET || s.length > MAX_SECRET)) throw new Error(`the secret must be ${MIN_SECRET} to ${MAX_SECRET} characters (or leave it out and seam makes one)`)
57
- const made = !s
58
- if (made) s = crypto.randomBytes(24).toString('hex')
59
- const b = bearer == null ? '' : String(bearer).trim()
60
- if (b.length > MAX_BEARER || /[\r\n]/.test(b)) throw new Error(`the bearer key must be one line of at most ${MAX_BEARER} characters`)
61
- return { url: u, secret: s, since: now(), made, ...(b ? { bearer: b } : {}) }
62
- }
63
-
64
- /** `sha256=<hex>`: HMAC-SHA256 with `secret` over `<ts>.<body>`. */
65
- export function signWebhook (secret, ts, body) {
66
- return 'sha256=' + crypto.createHmac('sha256', String(secret)).update(`${ts}.${body}`).digest('hex')
67
- }
68
-
69
- /** For receivers: whether `signature` is right for this body and timestamp. */
70
- export function verifyWebhook (secret, ts, body, signature) {
71
- const want = Buffer.from(signWebhook(secret, ts, body))
72
- const got = Buffer.from(String(signature || ''))
73
- return want.length === got.length && crypto.timingSafeEqual(want, got)
74
- }
75
-
76
- const retryable = (status) => status === 408 || status === 429 || status >= 500
77
-
78
- /** POSTs `payload` to `sub.url`, signed, with retries. Resolves { ok, status, attempts, delivery, error? }; never throws. */
79
- export async function deliverWebhook (sub, payload, { fetch = globalThis.fetch, delays = RETRY_DELAYS_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), timeoutMs = DELIVERY_TIMEOUT_MS, now = Date.now, log = () => {} } = {}) {
80
- const body = JSON.stringify(payload)
81
- const delivery = crypto.randomUUID()
82
- let status = 0
83
- let error = ''
84
- let attempt = 0
85
- while (attempt < delays.length + 1) {
86
- attempt++
87
- const ts = String(now())
88
- try {
89
- const res = await fetch(sub.url, {
90
- method: 'POST',
91
- headers: {
92
- 'content-type': 'application/json',
93
- 'user-agent': 'seam-webhook',
94
- 'x-quilt-event': String(payload.event || ''),
95
- 'x-quilt-delivery': delivery,
96
- 'x-quilt-timestamp': ts,
97
- 'x-quilt-signature': signWebhook(sub.secret, ts, body),
98
- ...(sub.bearer ? { authorization: `Bearer ${sub.bearer}` } : {})
99
- },
100
- body,
101
- redirect: 'manual',
102
- signal: AbortSignal.timeout(timeoutMs)
103
- })
104
- status = res.status
105
- error = ''
106
- if (res.ok) return { ok: true, status, attempts: attempt, delivery }
107
- if (!retryable(status)) break
108
- } catch (err) {
109
- status = 0
110
- error = err && err.message ? err.message : String(err)
111
- }
112
- if (attempt <= delays.length) await sleep(delays[attempt - 1])
113
- }
114
- log(`webhook to ${sub.url} failed (${payload.event}): ${error || `HTTP ${status}`}`)
115
- return { ok: false, status, attempts: attempt, error: error || `HTTP ${status}`, delivery }
116
- }
@@ -1,119 +0,0 @@
1
- // The merge window (plans/quilt-source-control/07 "AIMD on merges when main is red", 04 "Rate
2
- // limiting", decision 40): each line takes at most `window` merges an hour, 200 by default, and a
3
- // quilt at most 200 an hour across its lines. Merges are counted from the log itself (a line's
4
- // patches carry their time), so every API machine sees the same count with no counter to keep.
5
- // A red post-merge check halves the line's window (once per check, floor 5); that check going
6
- // green restores the window it halved; any other green batch adds 10, up to the line's maximum.
7
- // Above the window a merge is refused with 429 merge_window and `retry_after` seconds. A dry run
8
- // is never limited.
9
- import { QuiltError } from './service.js'
10
-
11
- export const HOUR = 3600 * 1000
12
- export const DEFAULT_WINDOW = 200
13
- export const FLOOR = 5
14
- export const GREEN_STEP = 10
15
-
16
- export function windowOps (svc, { max = DEFAULT_WINDOW, quiltMax = DEFAULT_WINDOW } = {}) {
17
- const { store, now, quiltFor, fromStore } = svc.internals
18
-
19
- const windowOf = (ln) => ({
20
- max: ln.merge_window_max ?? max,
21
- window: Math.min(ln.merge_window ?? ln.merge_window_max ?? max, ln.merge_window_max ?? max),
22
- red: ln.red_checks || {}
23
- })
24
-
25
- // The patches of a line merged in the last hour, at most `cap` of them, and the time the
26
- // oldest of those counted leaves the hour. Binary search over patch numbers: a line's patch
27
- // times only grow, so this is O(log cap) reads of the patch row, never a scan. A git import's
28
- // patches are not merges and don't count: they come before every merge on the line, so a
29
- // just-imported quilt takes its first merges at once instead of an hour later.
30
- async function lastHour (q, ln, cap, t) {
31
- const n = ln.patch_count
32
- if (!n || !cap) return { count: 0, oldest: null }
33
- let lo = Math.max(1, n - cap + 1); let hi = n + 1 // first patch inside the hour is in [lo, hi]
34
- const imported = new Map()
35
- const isImport = async (sq) => { if (!imported.has(sq)) imported.set(sq, !!(sq && (await store.getSquare(sq))?.import)); return imported.get(sq) }
36
- const inHour = async (p) => { const row = await store.patch(q, ln.name, p); return row && Number(row.at) > t - HOUR && !(await isImport(row.square)) ? Number(row.at) : null }
37
- if (await inHour(n) == null) return { count: 0, oldest: null }
38
- while (lo < hi) {
39
- const mid = (lo + hi) >> 1
40
- if (await inHour(mid) != null) hi = mid; else lo = mid + 1
41
- }
42
- return { count: n - lo + 1, oldest: await inHour(lo) }
43
- }
44
-
45
- /** Refuses a merge into `line` above its window or the quilt's (429 merge_window). */
46
- async function checkWindow (q, line) {
47
- const t = now()
48
- const ln = await store.getLine(q.id, line)
49
- if (!ln) return
50
- const w = windowOf(ln)
51
- const mine = await lastHour(q.id, ln, w.window, t)
52
- if (mine.count >= w.window) {
53
- throw new QuiltError(429, 'merge_window', `${line} has taken its ${w.window} merges for this hour${w.window < w.max ? ' (halved while a check is red)' : ''}; try again in ${Math.ceil((mine.oldest + HOUR - t) / 1000)} s`, { retry_after: Math.max(1, Math.ceil((mine.oldest + HOUR - t) / 1000)), window: w.window, window_max: w.max })
54
- }
55
- const lines = await store.lines(q.id)
56
- if (lines.length < 2) return
57
- let total = 0; let oldest = Infinity
58
- for (const other of lines) {
59
- const r = await lastHour(q.id, other, quiltMax, t)
60
- total += r.count
61
- if (r.oldest != null) oldest = Math.min(oldest, r.oldest)
62
- }
63
- if (total >= quiltMax) throw new QuiltError(429, 'merge_window', `this quilt has taken its ${quiltMax} merges for this hour across its lines`, { retry_after: Math.max(1, Math.ceil((oldest + HOUR - t) / 1000)), window: quiltMax })
64
- }
65
-
66
- async function setWindow (q, ln, next) {
67
- try {
68
- await store.setLineWindow({ quilt_id: q.id, line: ln.name, merge_window: next.window, merge_window_max: next.max, red_checks: next.red, at: now() })
69
- } catch (err) { throw fromStore(err) }
70
- return { line: ln.name, window: next.window, window_max: next.max, red: Object.keys(next.red).sort() }
71
- }
72
-
73
- /**
74
- * A post-merge check's result on a line (13 sends these; a CI hook or a person can too):
75
- * { check, status: 'red'|'green'|'flaky' }. Returns the line's window after it.
76
- */
77
- async function reportCheck (me, ref, line, body = {}) {
78
- const q = await quiltFor(me, ref, 'write')
79
- const ln = await store.getLine(q.id, line)
80
- if (!ln) throw new QuiltError(404, 'not_found', `no line ${line}`)
81
- const check = String(body.check || '').trim()
82
- if (!check || check.length > 200) throw new QuiltError(400, 'bad_request', 'check: the name of the check, up to 200 characters')
83
- if (!['red', 'green', 'flaky'].includes(body.status)) throw new QuiltError(400, 'bad_request', "status: 'red', 'green' or 'flaky'")
84
- const w = windowOf(ln)
85
- const red = { ...w.red }
86
- let window = w.window
87
- if (body.status === 'red') {
88
- if (red[check] == null) { red[check] = window; window = Math.max(FLOOR, Math.floor(window / 2)) } // once per check, not per red batch
89
- } else if (red[check] != null) {
90
- window = Math.max(window, red[check]) // green (or flaky) again: back to the window before that halving
91
- delete red[check]
92
- } else if (body.status === 'green') {
93
- window = Math.min(w.max, window + GREEN_STEP)
94
- }
95
- return setWindow(q, ln, { window, max: w.max, red })
96
- }
97
-
98
- /** An admin sets a line's maximum window (04's PATCH /lines/:name { merge_window_max }). */
99
- async function setLineLimits (me, ref, line, body = {}) {
100
- const q = await quiltFor(me, ref, 'admin')
101
- const ln = await store.getLine(q.id, line)
102
- if (!ln) throw new QuiltError(404, 'not_found', `no line ${line}`)
103
- const m = Number(body.merge_window_max)
104
- if (!Number.isInteger(m) || m < FLOOR || m > 100000) throw new QuiltError(400, 'bad_request', `merge_window_max: a whole number from ${FLOOR}`)
105
- const w = windowOf(ln)
106
- return setWindow(q, ln, { window: Math.min(m, w.window === w.max ? m : w.window), max: m, red: w.red })
107
- }
108
-
109
- async function lineWindow (me, ref, line) {
110
- const q = await quiltFor(me, ref, 'read')
111
- const ln = await store.getLine(q.id, line)
112
- if (!ln) throw new QuiltError(404, 'not_found', `no line ${line}`)
113
- const w = windowOf(ln)
114
- const used = await lastHour(q.id, ln, w.window, now())
115
- return { line, window: w.window, window_max: w.max, used: used.count, red: Object.keys(w.red).sort() }
116
- }
117
-
118
- return { checkWindow, reportCheck, setLineLimits, lineWindow }
119
- }