@heyquilt/seam 0.26.0 → 0.130.0
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/bin/seam.js +118 -17
- package/lib/quilts/agent-tokens.js +148 -0
- package/lib/quilts/archiver.js +241 -0
- package/lib/quilts/attest.js +212 -0
- package/lib/quilts/bisect-run.js +3 -2
- package/lib/quilts/block.js +497 -55
- package/lib/quilts/cas.js +78 -0
- package/lib/quilts/cbor.js +2 -0
- package/lib/quilts/claims.js +409 -0
- package/lib/quilts/cli.js +623 -69
- package/lib/quilts/client.js +307 -19
- package/lib/quilts/conflict-format.js +266 -0
- package/lib/quilts/daemon.js +174 -0
- package/lib/quilts/describe.js +281 -38
- package/lib/quilts/dict.js +34 -0
- package/lib/quilts/exit-archive.js +184 -0
- package/lib/quilts/export-format.js +52 -0
- package/lib/quilts/export-git.js +27 -3
- package/lib/quilts/export-lfs.js +52 -0
- package/lib/quilts/export-sign.js +49 -0
- package/lib/quilts/fastcdc.js +186 -0
- package/lib/quilts/hash.js +17 -2
- package/lib/quilts/ignored.js +46 -0
- package/lib/quilts/import-git.js +54 -2
- package/lib/quilts/journal.js +105 -0
- package/lib/quilts/limits.js +197 -0
- package/lib/quilts/linepolicy.js +109 -0
- package/lib/quilts/pack.js +146 -18
- package/lib/quilts/persona.js +70 -0
- package/lib/quilts/records.js +7 -3
- package/lib/quilts/recover.js +37 -0
- package/lib/quilts/runner.js +79 -12
- package/lib/quilts/sealed.js +231 -0
- package/lib/quilts/secrets.js +86 -0
- package/lib/quilts/signing.js +7 -3
- package/lib/quilts/tarball.js +107 -0
- package/lib/quilts/textdiff.js +7 -5
- package/lib/quilts/tools.js +412 -83
- package/lib/quilts/tree.js +351 -0
- package/lib/quilts/want.js +51 -0
- package/lib/quilts/webhook.js +116 -0
- package/package.json +3 -2
|
@@ -0,0 +1,351 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The want-list answer's framing (04 "Object protocol: want-only fetch"): every found object's
|
|
2
|
+
// bytes as they are, then a JSON trailer. No base64, so an answer is the size of what it carries.
|
|
3
|
+
//
|
|
4
|
+
// "SEAMOBJ1" 8 bytes
|
|
5
|
+
// per object: kind (1 byte) · id (32 bytes) · length (u32 BE) · bytes
|
|
6
|
+
// kind 0 · length (u32 BE) · trailer JSON { missing, later?, redacted?, too_large? }
|
|
7
|
+
//
|
|
8
|
+
// Kinds: 1 blob, 2 tree, 3 commit, 4 term. `missing`: ids this caller cannot have (not there, or
|
|
9
|
+
// not theirs: the two are not told apart); `later`: ids left out because the answer was full,
|
|
10
|
+
// to ask for again.
|
|
11
|
+
const MAGIC = Buffer.from('SEAMOBJ1')
|
|
12
|
+
export const WANT_KINDS = ['blob', 'tree', 'commit', 'term']
|
|
13
|
+
export const WANT_MAX = 1000
|
|
14
|
+
export const WANT_CONTENT_TYPE = 'application/x-seam-objects'
|
|
15
|
+
|
|
16
|
+
export function encodeObjects (objects, trailer = {}) {
|
|
17
|
+
const parts = [MAGIC]
|
|
18
|
+
for (const o of objects) {
|
|
19
|
+
const head = Buffer.alloc(37)
|
|
20
|
+
head[0] = WANT_KINDS.indexOf(o.kind) + 1
|
|
21
|
+
Buffer.from(o.id, 'hex').copy(head, 1)
|
|
22
|
+
head.writeUInt32BE(o.bytes.length, 33)
|
|
23
|
+
parts.push(head, o.bytes)
|
|
24
|
+
}
|
|
25
|
+
const t = Buffer.from(JSON.stringify(trailer))
|
|
26
|
+
const end = Buffer.alloc(5)
|
|
27
|
+
end.writeUInt32BE(t.length, 1)
|
|
28
|
+
parts.push(end, t)
|
|
29
|
+
return Buffer.concat(parts)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function decodeObjects (buf) {
|
|
33
|
+
buf = Buffer.from(buf)
|
|
34
|
+
if (buf.length < 13 || !buf.subarray(0, 8).equals(MAGIC)) throw new Error('not a want-list answer (SEAMOBJ1)')
|
|
35
|
+
const objects = []
|
|
36
|
+
let at = 8
|
|
37
|
+
for (;;) {
|
|
38
|
+
if (at + 5 > buf.length) throw new Error('a want-list answer ended early')
|
|
39
|
+
const kind = buf[at]
|
|
40
|
+
if (kind === 0) {
|
|
41
|
+
const n = buf.readUInt32BE(at + 1)
|
|
42
|
+
if (at + 5 + n !== buf.length) throw new Error('a want-list answer has a bad trailer')
|
|
43
|
+
return { objects, trailer: JSON.parse(buf.subarray(at + 5).toString('utf8')) }
|
|
44
|
+
}
|
|
45
|
+
if (!WANT_KINDS[kind - 1] || at + 37 > buf.length) throw new Error('a want-list answer has a bad frame')
|
|
46
|
+
const n = buf.readUInt32BE(at + 33)
|
|
47
|
+
if (at + 37 + n > buf.length) throw new Error('a want-list answer ended early')
|
|
48
|
+
objects.push({ kind: WANT_KINDS[kind - 1], id: buf.subarray(at + 1, at + 33).toString('hex'), bytes: buf.subarray(at + 37, at + 37 + n) })
|
|
49
|
+
at += 37 + n
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyquilt/seam",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "seam:
|
|
3
|
+
"version": "0.130.0",
|
|
4
|
+
"description": "seam: Seam's command. Git's verbs for source control that runs on the server, built for many agents and people at once.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
25
25
|
"@noble/hashes": "^2.4.0",
|
|
26
26
|
"ignore": "^7.0.10",
|
|
27
|
+
"node-diff3": "^3.2.1",
|
|
27
28
|
"zod": "^4.6.5"
|
|
28
29
|
}
|
|
29
30
|
}
|