heyquilt 0.3.122 → 0.3.138
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASES.md +273 -17
- package/bin/quilt.js +55 -9
- package/desktop/main.js +16 -2
- package/package.json +3 -2
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +16 -9
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/session-migrate.js +7 -4
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +13 -7
- package/src/api/supabase-store.js +43 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +36 -9
- package/src/control.js +129 -15
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +13 -3
- package/src/mcp.js +87 -12
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +151 -17
- package/src/relay-outbound.js +1 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1517 -86
- package/src/session-follow.js +461 -27
- package/src/session-layout3.js +410 -4
- package/src/session-migrate.js +15 -11
- package/src/session-quilt.js +1 -1
- package/src/session-seam.js +233 -20
- package/src/session.js +848 -67
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +504 -33
- package/src/source-live/live-square.js +16 -5
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/accounts.js +0 -136
- package/src/quilts/affected.js +0 -132
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/checks.js +0 -308
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -536
- package/src/quilts/client.js +0 -137
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -296
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -171
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export-remote.js +0 -156
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-follow.js +0 -236
- package/src/quilts/import-git.js +0 -294
- package/src/quilts/import.js +0 -196
- package/src/quilts/index.js +0 -94
- package/src/quilts/keys.js +0 -186
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -501
- package/src/quilts/oauth.js +0 -202
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -208
- package/src/quilts/routes.js +0 -186
- package/src/quilts/runner.js +0 -95
- package/src/quilts/server.js +0 -144
- package/src/quilts/service.js +0 -963
- package/src/quilts/setup.js +0 -41
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -844
- package/src/quilts/store-rpc.js +0 -90
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -403
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -121
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
package/src/quilts/tree.js
DELETED
|
@@ -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
|
-
}
|
package/src/quilts/webhook.js
DELETED
|
@@ -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
|
-
}
|
package/src/quilts/window.js
DELETED
|
@@ -1,121 +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 noted = svc.internals.noteVerdict ? await svc.internals.noteVerdict(me, q, { name: check, tier: 'post_merge', commit: ln.tip, line, verdict: body.status, run_key: body.run_key, url: body.url }) : {}
|
|
85
|
-
if (noted.advisory) { const w0 = windowOf(ln); return { line, window: w0.window, window_max: w0.max, red: Object.keys(w0.red).sort(), advisory: true } }
|
|
86
|
-
const w = windowOf(ln)
|
|
87
|
-
const red = { ...w.red }
|
|
88
|
-
let window = w.window
|
|
89
|
-
if (body.status === 'red') {
|
|
90
|
-
if (red[check] == null) { red[check] = window; window = Math.max(FLOOR, Math.floor(window / 2)) } // once per check, not per red batch
|
|
91
|
-
} else if (red[check] != null) {
|
|
92
|
-
window = Math.max(window, red[check]) // green (or flaky) again: back to the window before that halving
|
|
93
|
-
delete red[check]
|
|
94
|
-
} else if (body.status === 'green') {
|
|
95
|
-
window = Math.min(w.max, window + GREEN_STEP)
|
|
96
|
-
}
|
|
97
|
-
return setWindow(q, ln, { window, max: w.max, red })
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** An admin sets a line's maximum window (04's PATCH /lines/:name { merge_window_max }). */
|
|
101
|
-
async function setLineLimits (me, ref, line, body = {}) {
|
|
102
|
-
const q = await quiltFor(me, ref, 'admin')
|
|
103
|
-
const ln = await store.getLine(q.id, line)
|
|
104
|
-
if (!ln) throw new QuiltError(404, 'not_found', `no line ${line}`)
|
|
105
|
-
const m = Number(body.merge_window_max)
|
|
106
|
-
if (!Number.isInteger(m) || m < FLOOR || m > 100000) throw new QuiltError(400, 'bad_request', `merge_window_max: a whole number from ${FLOOR}`)
|
|
107
|
-
const w = windowOf(ln)
|
|
108
|
-
return setWindow(q, ln, { window: Math.min(m, w.window === w.max ? m : w.window), max: m, red: w.red })
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async function lineWindow (me, ref, line) {
|
|
112
|
-
const q = await quiltFor(me, ref, 'read')
|
|
113
|
-
const ln = await store.getLine(q.id, line)
|
|
114
|
-
if (!ln) throw new QuiltError(404, 'not_found', `no line ${line}`)
|
|
115
|
-
const w = windowOf(ln)
|
|
116
|
-
const used = await lastHour(q.id, ln, w.window, now())
|
|
117
|
-
return { line, window: w.window, window_max: w.max, used: used.count, red: Object.keys(w.red).sort() }
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return { checkWindow, reportCheck, setLineLimits, lineWindow }
|
|
121
|
-
}
|