@heyquilt/seam 0.27.0 → 0.131.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 +612 -69
- package/lib/quilts/client.js +306 -19
- package/lib/quilts/conflict-format.js +266 -0
- package/lib/quilts/daemon.js +174 -0
- package/lib/quilts/describe.js +296 -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 +404 -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
package/lib/quilts/client.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// The quilt API from a client (the CLI, the local MCP, the importer): one function per route.
|
|
2
2
|
// A refusal throws an Error with the server's `code`, `status` and fields (head, terms, ...).
|
|
3
|
-
import { buildPack } from './pack.js'
|
|
3
|
+
import { buildPack, MAX_PACK } from './pack.js'
|
|
4
|
+
import { chunkStream, manifestBuilder, CDC_THRESHOLD } from './fastcdc.js'
|
|
5
|
+
import { idOf, idHasher } from './hash.js'
|
|
6
|
+
import { decodeObjects } from './want.js'
|
|
4
7
|
|
|
5
8
|
const enc = encodeURIComponent
|
|
6
9
|
const qs = (o) => {
|
|
@@ -9,12 +12,42 @@ const qs = (o) => {
|
|
|
9
12
|
}
|
|
10
13
|
const pathPart = (p) => String(p).split('/').map(enc).join('/')
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
/** Raw bytes in one client pack before the uploader starts another (under pack.js's 64 MiB). */
|
|
16
|
+
export const PACK_BYTES = 48 * 1024 * 1024
|
|
17
|
+
// A pack this small goes inline (one POST /packs) even where the server signs uploads: a signed
|
|
18
|
+
// upload costs three requests (target, PUT, sealed), and a few small files are most commits.
|
|
19
|
+
export const INLINE_PACK_BYTES = 1024 * 1024
|
|
20
|
+
// An upload this small skips POST /have and sends everything: the server keeps what it has.
|
|
21
|
+
export const SKIP_HAVE_BYTES = 256 * 1024
|
|
22
|
+
|
|
23
|
+
/** A streamed object's bytes whole, for a server that takes no chunks; over 64 MiB it is refused as buildPack refuses it, before any read. */
|
|
24
|
+
async function wholeOf (o) {
|
|
25
|
+
if (o.bytes) return o.bytes
|
|
26
|
+
if (o.size > MAX_PACK) throw Object.assign(new Error(`${o.kind} ${String(o.id).slice(0, 12)} is ${o.size} bytes whole, over 64 MiB: store it in chunks (SEAM_CHUNKING=1)`), { code: 'pack_too_large' })
|
|
27
|
+
const parts = []
|
|
28
|
+
for await (const p of o.open()) parts.push(Buffer.from(p))
|
|
29
|
+
return Buffer.concat(parts)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `packBytes`: raw bytes in one uploaded pack (default 48 MiB); a large file is cut and sent as it
|
|
34
|
+
* is read, so the uploader holds about two packs' worth of it at most (10, streaming the cut).
|
|
35
|
+
*/
|
|
36
|
+
export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch, timeoutMs = 120_000, packBytes = PACK_BYTES, inlineBytes = INLINE_PACK_BYTES, skipHaveBytes = SKIP_HAVE_BYTES }) {
|
|
13
37
|
const base = String(api || '').replace(/\/+$/, '')
|
|
14
|
-
if (!base) throw new Error('No
|
|
38
|
+
if (!base) throw new Error('No Seam server: set SEAM_API_URL (or sign in with `seam login`).')
|
|
15
39
|
const auth = token ? (token.startsWith('QuiltPass ') ? token : `Bearer ${token}`) : null
|
|
16
40
|
|
|
17
|
-
async function call (method, route, body,
|
|
41
|
+
async function call (method, route, body, opts = {}) {
|
|
42
|
+
// 202 deriving (08): the server is working the answer out; ask again after its Retry-After.
|
|
43
|
+
for (let tries = 0; ; tries++) {
|
|
44
|
+
const r = await once(method, route, body, opts)
|
|
45
|
+
if (!(r && r.code === 'deriving' && r.job) || tries >= 120) return r
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(5, Math.max(0.05, Number(r.retry_after) || 1)) * 1000))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function once (method, route, body, { raw = false } = {}) {
|
|
18
51
|
let res
|
|
19
52
|
try {
|
|
20
53
|
res = await fetchImpl(base + route, {
|
|
@@ -34,14 +67,109 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
|
|
|
34
67
|
return data
|
|
35
68
|
}
|
|
36
69
|
|
|
70
|
+
// A route that answers bytes (a tarball): { bytes, name, commit }; a refusal is JSON as usual.
|
|
71
|
+
async function bytes (route, method = 'GET') {
|
|
72
|
+
let res
|
|
73
|
+
try {
|
|
74
|
+
res = await fetchImpl(base + route, { method, headers: auth ? { authorization: auth } : {}, signal: AbortSignal.timeout(timeoutMs) })
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new Error(`Couldn't reach the quilt server at ${base} (${err?.cause?.code || err?.name || 'no answer'}).`)
|
|
77
|
+
}
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
const { error, ...extra } = (await res.json().catch(() => null)) || {}
|
|
80
|
+
throw Object.assign(new Error(error || `The quilt server answered ${res.status}.`), { status: res.status, ...extra })
|
|
81
|
+
}
|
|
82
|
+
const name = (/filename="([^"]+)"/.exec(res.headers.get('content-disposition') || '') || [])[1] || null
|
|
83
|
+
return { bytes: Buffer.from(await res.arrayBuffer()), name, commit: res.headers.get('x-seam-commit') }
|
|
84
|
+
}
|
|
85
|
+
|
|
37
86
|
const Q = (q) => `/v1/quilts/${enc(q)}`
|
|
87
|
+
let chunksRefused = false // this server takes no client chunks (no SEAM_CHUNKING): ask once
|
|
88
|
+
let wantOff = false // this server answers no want-lists (POST /objects, 04): ask once, then /blobs
|
|
89
|
+
let signedOff = false // this server takes packs posted to /packs only (no SEAM_SIGNED_UPLOADS): ask once
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A want-list (04): `list` is [{ id, kind? }] (≤ 1,000), answered as the objects' own bytes.
|
|
93
|
+
* Every object is checked against its id before it is handed on (a sealed quilt's blobs, a
|
|
94
|
+
* redaction's tombstone and a damaged file's are checked by whoever opens them).
|
|
95
|
+
*/
|
|
96
|
+
async function want (q, list, rev) {
|
|
97
|
+
let res
|
|
98
|
+
try {
|
|
99
|
+
res = await fetchImpl(base + `${Q(q)}/objects`, { method: 'POST', headers: { ...(auth ? { authorization: auth } : {}), 'content-type': 'application/json' }, body: JSON.stringify({ want: list, ...(rev ? { rev } : {}) }), signal: AbortSignal.timeout(timeoutMs) })
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new Error(`Couldn't reach the quilt server at ${base} (${err?.cause?.code || err?.name || 'no answer'}).`)
|
|
102
|
+
}
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
const { error, ...extra } = (await res.json().catch(() => null)) || {}
|
|
105
|
+
throw Object.assign(new Error(error || `The quilt server answered ${res.status}.`), { status: res.status, ...extra })
|
|
106
|
+
}
|
|
107
|
+
const { objects, trailer } = decodeObjects(Buffer.from(await res.arrayBuffer()))
|
|
108
|
+
const unchecked = new Set([...Object.keys(trailer.redacted || {}), ...(trailer.damaged || [])])
|
|
109
|
+
const out = new Map()
|
|
110
|
+
for (const o of objects) {
|
|
111
|
+
if (!(o.kind === 'blob' && (trailer.sealed || unchecked.has(o.id))) && idOf(o.kind, o.bytes) !== o.id) throw Object.assign(new Error(`the bytes the server sent for ${o.kind} ${o.id.slice(0, 12)} do not match its id`), { code: 'id_mismatch' })
|
|
112
|
+
out.set(o.id, o)
|
|
113
|
+
}
|
|
114
|
+
return { objects: out, missing: trailer.missing || [], later: trailer.later || [], redacted: trailer.redacted || {}, too_large: trailer.too_large || {}, damaged: trailer.damaged || [], sealed: !!trailer.sealed }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Files by id in /blobs's shape ({ blobs: { id: base64 }, redacted, too_large }), by want-list where the server answers one. */
|
|
118
|
+
async function wantBlobs (q, ids, rev) {
|
|
119
|
+
if (!wantOff) {
|
|
120
|
+
try {
|
|
121
|
+
const blobs = {}; const redacted = {}; const tooLarge = {}
|
|
122
|
+
// An id, or { id, path, ticket } (04 rule 7).
|
|
123
|
+
const asks = new Map(ids.map((x) => typeof x === 'string' ? [x, { id: x }] : [x.id, x]))
|
|
124
|
+
let left = [...asks.keys()]
|
|
125
|
+
while (left.length) {
|
|
126
|
+
const r = await want(q, left.map((id) => asks.get(id)), rev)
|
|
127
|
+
for (const [id, o] of r.objects) blobs[id] = o.bytes.toString('base64')
|
|
128
|
+
Object.assign(redacted, r.redacted); Object.assign(tooLarge, r.too_large)
|
|
129
|
+
if (!r.objects.size || !r.later.length) break
|
|
130
|
+
left = r.later
|
|
131
|
+
}
|
|
132
|
+
return { blobs, ...(Object.keys(redacted).length ? { redacted } : {}), ...(Object.keys(tooLarge).length ? { too_large: tooLarge } : {}) }
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err.status !== 404 && err.status !== 405) throw err
|
|
135
|
+
wantOff = true
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return call('POST', `${Q(q)}/blobs`, { ids: ids.map((x) => typeof x === 'string' ? x : x.id), ...(rev ? { rev } : {}) })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* One pack up: by a signed PUT straight to object storage and then `sealed` (04 "Uploads"),
|
|
143
|
+
* where the server offers it, else posted to /packs. The Seam token never goes to storage.
|
|
144
|
+
*/
|
|
145
|
+
async function sendPack (q, pack) {
|
|
146
|
+
if (!signedOff && pack.bytes.length > inlineBytes) {
|
|
147
|
+
let t
|
|
148
|
+
try { t = await call('POST', `${Q(q)}/packs/${enc(pack.id)}/target`, { bytes: pack.bytes.length }) } catch (err) { if (err.status !== 404 && err.status !== 405) throw err; t = { inline: true } }
|
|
149
|
+
if (t.inline) signedOff = true
|
|
150
|
+
else {
|
|
151
|
+
if (t.sealed) return
|
|
152
|
+
if (t.put_url) {
|
|
153
|
+
let res
|
|
154
|
+
try { res = await fetchImpl(t.put_url, { method: t.method || 'PUT', headers: { 'content-type': 'application/octet-stream', 'x-upsert': 'false' }, body: pack.bytes, signal: AbortSignal.timeout(timeoutMs) }) } catch (err) { throw new Error(`Couldn't reach object storage for pack ${pack.id.slice(0, 12)} (${err?.cause?.code || err?.name || 'no answer'}).`) }
|
|
155
|
+
// 409: the same bytes are there already (the key is their hash).
|
|
156
|
+
if (!res.ok && res.status !== 409) throw Object.assign(new Error(`Object storage answered ${res.status} to pack ${pack.id.slice(0, 12)}.`), { status: res.status })
|
|
157
|
+
}
|
|
158
|
+
await call('POST', `${Q(q)}/packs/${enc(pack.id)}/sealed`)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
await call('POST', `${Q(q)}/packs`, pack.bytes, { raw: true })
|
|
163
|
+
}
|
|
164
|
+
let chunksSent = false // a chunk pack was taken: the probe is done
|
|
38
165
|
return {
|
|
166
|
+
api: base,
|
|
39
167
|
call,
|
|
40
168
|
quilts: async () => (await call('GET', '/v1/quilts')).quilts,
|
|
41
169
|
createQuilt: (b) => call('POST', '/v1/quilts', b),
|
|
42
170
|
quilt: (q) => call('GET', Q(q)),
|
|
43
171
|
addMember: (q, b) => call('POST', `${Q(q)}/members`, b),
|
|
44
|
-
squares: async (q, f = {}) => (await call('GET', `${Q(q)}/squares${qs({ state: f.state, mine: f.mine ? 1 : undefined, task: f.task })}`)).squares,
|
|
172
|
+
squares: async (q, f = {}) => (await call('GET', `${Q(q)}/squares${qs({ state: f.state, mine: f.mine ? 1 : undefined, hidden: f.hidden ? 1 : undefined, archived: f.archived ? 1 : undefined, task: f.task })}`)).squares,
|
|
45
173
|
openSquare: (q, b) => call('POST', `${Q(q)}/squares`, b),
|
|
46
174
|
square: (q, s) => call('GET', `${Q(q)}/squares/${enc(s)}`),
|
|
47
175
|
commit: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/commit`, b),
|
|
@@ -50,10 +178,19 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
|
|
|
50
178
|
abandon: (q, s, b = {}) => call('POST', `${Q(q)}/squares/${enc(s)}/abandon`, b),
|
|
51
179
|
squareMerged: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/merged`, b),
|
|
52
180
|
reopen: (q, s) => call('POST', `${Q(q)}/squares/${enc(s)}/reopen`, {}),
|
|
181
|
+
archive: (q, s, body = {}) => call('POST', `${Q(q)}/squares/${enc(s)}/archive`, body),
|
|
182
|
+
unarchive: (q, s) => call('POST', `${Q(q)}/squares/${enc(s)}/unarchive`, {}),
|
|
183
|
+
hide: (q, s) => call('POST', `${Q(q)}/squares/${enc(s)}/hide`, {}),
|
|
184
|
+
unhide: (q, s) => call('POST', `${Q(q)}/squares/${enc(s)}/unhide`, {}),
|
|
185
|
+
editSquare: (q, s, b) => call('PATCH', `${Q(q)}/squares/${enc(s)}`, b),
|
|
186
|
+
share: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/share`, b),
|
|
187
|
+
unshare: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/unshare`, b),
|
|
53
188
|
mergeSquare: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/merge`, b),
|
|
189
|
+
absorb: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/absorb`, b),
|
|
54
190
|
fold: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/fold`, b),
|
|
55
191
|
split: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/split`, b),
|
|
56
192
|
rebase: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/rebase`, b),
|
|
193
|
+
reparent: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/reparent`, b),
|
|
57
194
|
cherryPick: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/cherry-pick`, b),
|
|
58
195
|
resolve: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/resolve`, b),
|
|
59
196
|
lines: async (q) => (await call('GET', `${Q(q)}/lines`)).lines,
|
|
@@ -74,6 +211,10 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
|
|
|
74
211
|
exportPatches: (q, o = {}) => call('GET', `${Q(q)}/export/git${qs(o)}`),
|
|
75
212
|
log: (q, o = {}) => call('GET', `${Q(q)}/log${qs(o)}`),
|
|
76
213
|
sign: (q, rev, b) => call('POST', `${Q(q)}/commits/${enc(rev)}/signatures`, b),
|
|
214
|
+
blocks: (q, o = {}) => call('GET', `${Q(q)}/blocks${qs(o)}`),
|
|
215
|
+
openBlock: (q, b) => call('POST', `${Q(q)}/blocks`, b),
|
|
216
|
+
touchBlock: (q, id, b = {}) => call('POST', `${Q(q)}/blocks/${enc(id)}`, b),
|
|
217
|
+
closeBlock: (q, id) => call('DELETE', `${Q(q)}/blocks/${enc(id)}`),
|
|
77
218
|
tokens: (q) => call('GET', `${Q(q)}/tokens`),
|
|
78
219
|
createToken: (q, b) => call('POST', `${Q(q)}/tokens`, b),
|
|
79
220
|
revokeToken: (q, id) => call('DELETE', `${Q(q)}/tokens/${enc(id)}`),
|
|
@@ -81,59 +222,205 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
|
|
|
81
222
|
addKey: (b) => call('POST', '/v1/signing-keys', b),
|
|
82
223
|
removeKey: (key) => call('DELETE', `/v1/signing-keys/${enc(key)}`),
|
|
83
224
|
describe: (q, rev) => call('GET', `${Q(q)}/describe${qs({ rev })}`),
|
|
225
|
+
watches: async (q, { all = false } = {}) => (await call('GET', `${Q(q)}/watches${qs({ all: all ? 1 : undefined })}`)).watches,
|
|
226
|
+
watch: (q, b) => call('POST', `${Q(q)}/watches`, b),
|
|
227
|
+
unwatch: (q, id) => call('DELETE', `${Q(q)}/watches/${enc(id)}`),
|
|
228
|
+
pipeline: (q, line, check) => call('GET', `${Q(q)}/lines/${enc(line)}/checks/${enc(check)}/pipeline`),
|
|
229
|
+
stopPipeline: (q, line, check) => call('POST', `${Q(q)}/lines/${enc(line)}/checks/${enc(check)}/pipeline/stop`, {}),
|
|
230
|
+
stack: (q, rev) => call('GET', `${Q(q)}/stack/${encodeURIComponent(rev || 'main')}`),
|
|
231
|
+
stackDiff: (q, from, to) => call('GET', `${Q(q)}/stack-diff${qs({ from, to })}`),
|
|
84
232
|
mergeBase: (q, a, b) => call('GET', `${Q(q)}/merge-base${qs({ a, b })}`),
|
|
233
|
+
ancestor: (q, a, b) => call('GET', `${Q(q)}/ancestor${qs({ a, b })}`),
|
|
234
|
+
contains: (q, rev) => call('GET', `${Q(q)}/contains/${enc(rev)}`),
|
|
235
|
+
derivation: (q, job) => call('GET', `${Q(q)}/derivations/${enc(job)}`),
|
|
85
236
|
grep: (q, o = {}) => call('GET', `${Q(q)}/grep${qs(o)}`),
|
|
86
237
|
search: (o = {}) => call('GET', `/v1/search${qs(o)}`),
|
|
238
|
+
searchCode: (q, o = {}) => call('GET', `${Q(q)}/search${qs(o)}`),
|
|
87
239
|
bisect: (q, b) => call('POST', `${Q(q)}/bisect`, b),
|
|
88
240
|
linePatches: (q, line = 'main', o = {}) => call('GET', `${Q(q)}/lines/${encodeURIComponent(line)}/patches${qs(o)}`),
|
|
89
241
|
diff: (q, o = {}) => call('GET', `${Q(q)}/diff${qs(o)}`),
|
|
90
|
-
resolutions: async (q, p) => (await call('GET', `${Q(q)}/resolutions${p ?
|
|
242
|
+
resolutions: async (q, p, { hunks = false } = {}) => (await call('GET', `${Q(q)}/resolutions${qs({ path: p || undefined, hunks: hunks ? 1 : undefined })}`)).resolutions,
|
|
91
243
|
forgetResolution: (q, key) => call('DELETE', `${Q(q)}/resolutions/${enc(key)}`),
|
|
92
244
|
reviews: (q, sq) => call('GET', `${Q(q)}/squares/${enc(sq)}/reviews`),
|
|
245
|
+
interdiff: (q, sq, o = {}) => call('GET', `${Q(q)}/squares/${enc(sq)}/interdiff${qs(o)}`),
|
|
93
246
|
review: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/reviews`, b),
|
|
247
|
+
applySuggestion: (q, sq, r) => call('POST', `${Q(q)}/squares/${enc(sq)}/reviews/${enc(r)}/apply`, {}),
|
|
94
248
|
reportSquareCheck: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/checks`, b),
|
|
95
249
|
rerunCheck: (q, { check, square, line, commit }) => call('POST', square ? `${Q(q)}/squares/${enc(square)}/checks/${enc(check)}/rerun` : `${Q(q)}/lines/${enc(line || 'main')}/checks/${enc(check)}/rerun`, commit ? { commit } : {}),
|
|
96
250
|
checkRuns: (q, o = {}) => call('GET', `${Q(q)}/checks${qs(o)}`),
|
|
251
|
+
squarePolicy: (q, sq, o = {}) => call('GET', `${Q(q)}/squares/${enc(sq)}/policy${qs(o)}`),
|
|
252
|
+
linePolicyVersions: (q, line) => call('GET', `${Q(q)}/lines/${enc(line)}/policy/versions`),
|
|
253
|
+
decideLinePolicy: (q, line, version, decision) => call('POST', `${Q(q)}/lines/${enc(line)}/policy/versions/${enc(version)}/${decision}`, {}),
|
|
254
|
+
confirmLinePolicy: (q, line, version, b) => call('POST', `${Q(q)}/lines/${enc(line)}/policy/versions/${enc(version)}/confirm`, b),
|
|
97
255
|
linePolicy: (q, line) => call('GET', `${Q(q)}/lines/${enc(line)}/policy`),
|
|
98
256
|
setLinePolicy: (q, line, b) => call('PUT', `${Q(q)}/lines/${enc(line)}/policy`, b),
|
|
99
257
|
claims: async (q, square) => (await call('GET', `${Q(q)}/claims${square ? `?square=${enc(square)}` : ''}`)).claims,
|
|
100
258
|
claim: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/claims`, b),
|
|
259
|
+
requestClaim: (q, b) => call('POST', `${Q(q)}/claims/requests`, b),
|
|
260
|
+
withdrawRequest: (q, id) => call('DELETE', `${Q(q)}/claims/requests/${enc(id)}`),
|
|
261
|
+
handoffClaim: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/claims/handoff`, b),
|
|
262
|
+
finishClaims: (q, sq, b = {}) => call('POST', `${Q(q)}/squares/${enc(sq)}/claims/finish`, b),
|
|
101
263
|
unclaim: (q, sq, which) => call('DELETE', `${Q(q)}/squares/${enc(sq)}/claims/${enc(which)}`),
|
|
102
264
|
live: (q, sq) => call('GET', `${Q(q)}/squares/${enc(sq)}/live`),
|
|
265
|
+
liveJoin: (q, sq, member) => call('GET', `${Q(q)}/squares/${enc(sq)}/live/join${member ? `?member=${enc(member)}` : ''}`),
|
|
103
266
|
setLive: (q, sq, body) => call('PUT', `${Q(q)}/squares/${enc(sq)}/live`, body),
|
|
104
|
-
|
|
267
|
+
patchLive: (q, sq, body) => call('PATCH', `${Q(q)}/squares/${enc(sq)}/live`, body),
|
|
268
|
+
exitArchive: (q) => bytes(`${Q(q)}/exit/archive`, 'POST'),
|
|
269
|
+
tarball: (q, rev, { paths } = {}) => bytes(`${Q(q)}/tarball/${enc(rev || 'main')}${qs({ paths })}`),
|
|
270
|
+
tree: (q, rev, paths, { nodes = false } = {}) => call('GET', `${Q(q)}/tree/${enc(rev || 'main')}${qs({ paths, ...(nodes ? { nodes: 1, classes: 1 } : {}) })}`),
|
|
105
271
|
cat: (q, rev, path, o = {}) => call('GET', `${Q(q)}/blob/${enc(rev || 'main')}/${pathPart(path)}${qs(o)}`),
|
|
106
|
-
|
|
272
|
+
/**
|
|
273
|
+
* A file's bytes as they stream (cat with raw, 10): { body (async iterable of bytes), size,
|
|
274
|
+
* blob, stone, sealed }. Nothing is held: the caller reads `body` as it comes. A body that
|
|
275
|
+
* fails part way throws as it is read. The time limit covers the answer's start, not its bytes.
|
|
276
|
+
*/
|
|
277
|
+
async catRaw (q, rev, path) {
|
|
278
|
+
const ctl = new AbortController()
|
|
279
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs)
|
|
280
|
+
let res
|
|
281
|
+
try {
|
|
282
|
+
res = await fetchImpl(base + `${Q(q)}/blob/${enc(rev || 'main')}/${pathPart(path)}?raw=1`, { headers: auth ? { authorization: auth } : {}, signal: ctl.signal })
|
|
283
|
+
} catch (err) {
|
|
284
|
+
throw new Error(`Couldn't reach the quilt server at ${base} (${err?.cause?.code || err?.name || 'no answer'}).`)
|
|
285
|
+
} finally { clearTimeout(timer) }
|
|
286
|
+
const type = res.headers.get('content-type') || ''
|
|
287
|
+
if (!res.ok || type.startsWith('application/json')) {
|
|
288
|
+
const data = await res.json().catch(() => null)
|
|
289
|
+
if (!res.ok) {
|
|
290
|
+
const { error, ...extra } = data || {}
|
|
291
|
+
throw Object.assign(new Error(error || `The quilt server answered ${res.status}.`), { status: res.status, ...extra })
|
|
292
|
+
}
|
|
293
|
+
// A server from before raw reads answers cat's JSON: its content is the file.
|
|
294
|
+
const bytes = Buffer.from(data?.content || '', 'base64')
|
|
295
|
+
return { body: [bytes], size: bytes.length, blob: data?.blob || null, stone: data?.redacted ? 'redacted' : null, sealed: !!data?.sealed }
|
|
296
|
+
}
|
|
297
|
+
return { body: res.body || [], size: Number(res.headers.get('content-length')), blob: res.headers.get('x-seam-blob'), stone: res.headers.get('x-seam-stone'), sealed: res.headers.get('x-seam-sealed') === '1' }
|
|
298
|
+
},
|
|
299
|
+
blame: (q, rev, path, { range, ignore, moves, reverse } = {}) => call('GET', `${Q(q)}/blame/${enc(rev || 'main')}/${pathPart(path)}${qs({ range: range ? [].concat(range).join(',') : undefined, ignore, moves: moves ? 1 : undefined, reverse })}`),
|
|
107
300
|
have: (q, ids) => call('POST', `${Q(q)}/have`, { ids }),
|
|
108
|
-
blobs: (q, ids) => call('POST', `${Q(q)}/blobs`, { ids }),
|
|
301
|
+
blobs: (q, ids, rev) => call('POST', `${Q(q)}/blobs`, { ids, ...(rev ? { rev } : {}) }),
|
|
302
|
+
want,
|
|
303
|
+
wantBlobs,
|
|
304
|
+
protections: (q) => call('GET', `${Q(q)}/protections`),
|
|
305
|
+
setProtections: (q, rows) => call('PUT', `${Q(q)}/protections`, { rows }),
|
|
306
|
+
teams: (q) => call('GET', `${Q(q)}/teams`),
|
|
307
|
+
setTeam: (q, name, members) => call('PUT', `${Q(q)}/teams/${enc(name)}`, { members }),
|
|
308
|
+
agentTokens: (q) => call('GET', `${Q(q)}/agent-tokens`),
|
|
309
|
+
agents: () => call('GET', '/v1/agents'),
|
|
310
|
+
createAgent: (b) => call('POST', '/v1/agents', b),
|
|
311
|
+
createAgentToken: (q, b) => call('POST', `${Q(q)}/agent-tokens`, b),
|
|
312
|
+
revokeAgentToken: (q, id) => call('DELETE', `${Q(q)}/agent-tokens/${enc(id)}`),
|
|
313
|
+
orgs: () => call('GET', '/v1/orgs'),
|
|
314
|
+
createOrg: (b) => call('POST', '/v1/orgs', b),
|
|
315
|
+
org: (o) => call('GET', `/v1/orgs/${enc(o)}`),
|
|
316
|
+
orgLog: (o, { limit, before } = {}) => call('GET', `/v1/orgs/${enc(o)}/log${qs({ limit, before })}`),
|
|
317
|
+
// A member id, or the email of a person who has signed in to Seam.
|
|
318
|
+
setOrgTeam: (o, name, members) => call('PUT', `/v1/orgs/${enc(o)}/teams/${enc(name)}`, { members }),
|
|
319
|
+
setOrgMember: (o, member, role) => String(member).includes('@') ? call('POST', `/v1/orgs/${enc(o)}/members`, { email: member, role }) : call('PUT', `/v1/orgs/${enc(o)}/members/${enc(member)}`, { role }),
|
|
320
|
+
setQuiltOwner: (q, org) => call('PUT', `${Q(q)}/owner`, { org }),
|
|
321
|
+
setAgentsMakeLines: (q, allow) => call('PUT', `${Q(q)}/agents-make-lines`, { allow }),
|
|
322
|
+
setPublic: (q, value) => call('PUT', `${Q(q)}/public`, { public: value }),
|
|
323
|
+
setSealed: (q, value) => call('PUT', `${Q(q)}/sealed`, { sealed: value }),
|
|
324
|
+
setMigrationsDir: (q, dir) => call('PUT', `${Q(q)}/migrations-dir`, { dir }),
|
|
325
|
+
setInfraPaths: (q, paths) => call('PUT', `${Q(q)}/infra-paths`, { paths }),
|
|
326
|
+
setOwnerIdleHours: (q, hours) => call('PUT', `${Q(q)}/owner-idle-hours`, { hours }),
|
|
327
|
+
publicReads: (q) => call('GET', `${Q(q)}/public-reads`),
|
|
328
|
+
setSecretScanning: (q, mode) => call('PUT', `${Q(q)}/secret-scanning`, { mode }),
|
|
329
|
+
addSealingKey: (publicKey) => call('PUT', '/v1/me/sealing-keys', { public_key: publicKey }),
|
|
330
|
+
sealingKeys: (member) => call('GET', `/v1/sealing-keys${qs({ member })}`),
|
|
331
|
+
seal: (q) => call('GET', `${Q(q)}/seal`),
|
|
332
|
+
grantSeal: (q, wraps) => call('PUT', `${Q(q)}/seal/wraps`, { wraps }),
|
|
333
|
+
rotateSeal: (q, version, wraps) => call('POST', `${Q(q)}/seal/rotate`, { version, wraps }),
|
|
334
|
+
redact: (q, b) => call('POST', `${Q(q)}/redact`, b),
|
|
335
|
+
redactions: (q) => call('GET', `${Q(q)}/redactions`),
|
|
336
|
+
metrics: (q) => call('GET', `${Q(q)}/metrics`),
|
|
337
|
+
attestations: (q, o = {}) => call('GET', `${Q(q)}/attestations${qs(o)}`),
|
|
338
|
+
verify: (q, o = {}) => call('GET', `${Q(q)}/verify${qs(o)}`),
|
|
339
|
+
attestationKeys: () => call('GET', '/.well-known/seam-keys.json'),
|
|
340
|
+
access: (q, path = '') => call('GET', `${Q(q)}/access${qs({ path })}`),
|
|
341
|
+
reportMismatch: (q, id) => call('POST', `${Q(q)}/sealed/mismatch`, { id }),
|
|
109
342
|
uploadPack: (q, bytes) => call('POST', `${Q(q)}/packs`, bytes, { raw: true }),
|
|
110
343
|
importCommits: (q, items, lines = []) => call('POST', `${Q(q)}/import/commits`, { items, ...(lines.length ? { lines } : {}) }),
|
|
344
|
+
importEmails: (q, emails) => call('POST', `${Q(q)}/import/emails`, { emails }),
|
|
111
345
|
importKnown: (q, shas) => call('POST', `${Q(q)}/import/known`, { shas }),
|
|
112
346
|
setExport: (q, b) => call('PUT', `${Q(q)}/export`, b),
|
|
113
347
|
exportStatus: (q) => call('GET', `${Q(q)}/export`),
|
|
114
348
|
startImport: (b) => call('POST', '/v1/imports', b),
|
|
115
349
|
github: () => call('GET', '/v1/github'),
|
|
116
350
|
importGithub: (b) => call('POST', '/v1/github/import', b),
|
|
351
|
+
answerImportAuthors: (id, b) => call('POST', `/v1/imports/${enc(id)}/authors`, b),
|
|
117
352
|
importStatus: (id) => call('GET', `/v1/imports/${enc(id)}`),
|
|
118
353
|
cutover: (id, b = {}) => call('POST', `/v1/imports/${enc(id)}/cutover`, b),
|
|
119
354
|
pokeImport: (id) => call('POST', `/v1/imports/${enc(id)}/poke`),
|
|
120
355
|
|
|
121
356
|
/**
|
|
122
|
-
* Uploads objects the server does not have yet
|
|
123
|
-
*
|
|
357
|
+
* Uploads objects the server does not have yet, in packs of at most `packBytes` raw, after
|
|
358
|
+
* asking `have` (04: want-only, one pack per commit for a typical commit). An object is
|
|
359
|
+
* { kind, id, bytes }, or a file too large to hold, { kind: 'blob', id, size, open }, where
|
|
360
|
+
* open() gives its bytes as an async iterable of pieces (fs.createReadStream).
|
|
124
361
|
*/
|
|
125
362
|
async upload (q, objs) {
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
363
|
+
const missingIds = async (ids) => {
|
|
364
|
+
const missing = new Set()
|
|
365
|
+
for (let i = 0; i < ids.length; i += 10000) for (const id of (await call('POST', `${Q(q)}/have`, { ids: ids.slice(i, i + 10000) })).missing) missing.add(id)
|
|
366
|
+
return missing
|
|
367
|
+
}
|
|
368
|
+
const missingOf = async (list) => { const m = await missingIds(list.map((o) => o.id)); return list.filter((o) => m.has(o.id)) }
|
|
369
|
+
const small = objs.every((o) => o.bytes) && objs.reduce((n, o) => n + o.bytes.length, 0) <= skipHaveBytes
|
|
370
|
+
let todo = small ? objs : await missingOf(objs)
|
|
371
|
+
let batch = []; let size = 0; let packs = 0; let chunked = 0
|
|
372
|
+
const flush = async () => { if (!batch.length) return; const pack = buildPack(batch); batch = []; size = 0; await sendPack(q, pack); packs++ }
|
|
373
|
+
const add = async (o) => {
|
|
374
|
+
if (size + o.bytes.length > packBytes) await flush()
|
|
133
375
|
batch.push(o); size += o.bytes.length
|
|
134
376
|
}
|
|
377
|
+
// 10: a file over 1 MiB goes as its chunks, only those the quilt lacks, when the server takes
|
|
378
|
+
// them (SEAM_CHUNKING). It is cut as it is read: chunks wait in `pending` until a pack's worth
|
|
379
|
+
// is in, `have` says which are missing, and those go in the next pack, so only about two packs
|
|
380
|
+
// of the file are ever held. The very first chunk travels alone, so a server without the flag
|
|
381
|
+
// refuses a small pack, and every file then goes whole as before. A sealed file's id is keyed,
|
|
382
|
+
// so it never matches the cut and goes whole too.
|
|
383
|
+
const sendChunked = async (o) => {
|
|
384
|
+
const whole = idHasher('blob')
|
|
385
|
+
const manifests = manifestBuilder()
|
|
386
|
+
let pending = []; let held = 0
|
|
387
|
+
const drain = async () => {
|
|
388
|
+
const list = pending; pending = []; held = 0
|
|
389
|
+
const seen = new Set()
|
|
390
|
+
const missing = await missingIds([...new Set(list.map((e) => e.id))])
|
|
391
|
+
for (const e of list) {
|
|
392
|
+
if (seen.has(e.id) || !missing.has(e.id)) continue
|
|
393
|
+
seen.add(e.id)
|
|
394
|
+
if (!chunksSent && e.kind === 'chunk') { await flush(); batch = [e]; await flush(); chunksSent = true; continue }
|
|
395
|
+
await add(e)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const pieces = async function * () { for await (const p of o.open ? o.open() : [o.bytes]) { whole.update(p); yield p } }
|
|
399
|
+
for await (const c of chunkStream(pieces())) {
|
|
400
|
+
pending.push({ kind: 'chunk', id: c.id, bytes: c.bytes }); held += c.size
|
|
401
|
+
const leaf = manifests.add(c)
|
|
402
|
+
if (leaf) pending.push({ kind: 'manifest', id: leaf.id, bytes: leaf.bytes })
|
|
403
|
+
if (held >= packBytes) await drain()
|
|
404
|
+
}
|
|
405
|
+
if (whole.digest() !== o.id || (o.size != null && whole.size !== o.size)) throw Object.assign(new Error(`a file (blob ${o.id.slice(0, 12)}) changed while it was being uploaded: commit again`), { code: 'changed_during_upload' })
|
|
406
|
+
const root = manifests.finish()
|
|
407
|
+
for (const m of root.manifests) pending.push({ kind: 'manifest', id: m.id, bytes: m.bytes })
|
|
408
|
+
await drain()
|
|
409
|
+
await add({ kind: 'chunked', id: o.id, bytes: root.bytes }); await flush()
|
|
410
|
+
}
|
|
411
|
+
for (const o of todo.filter((x) => x.kind === 'blob' && (x.bytes ? x.bytes.length : x.size) > CDC_THRESHOLD)) {
|
|
412
|
+
if (chunksRefused) break
|
|
413
|
+
if (o.bytes && idOf('blob', o.bytes) !== o.id) continue
|
|
414
|
+
try { await sendChunked(o) } catch (err) {
|
|
415
|
+
batch = []; size = 0
|
|
416
|
+
if (err.code !== 'pack_corrupt') throw err
|
|
417
|
+
chunksRefused = true; break
|
|
418
|
+
}
|
|
419
|
+
todo = todo.filter((x) => x !== o); chunked++
|
|
420
|
+
}
|
|
421
|
+
for (const o of todo) await add(o.bytes ? o : { kind: o.kind, id: o.id, bytes: await wholeOf(o) })
|
|
135
422
|
await flush()
|
|
136
|
-
return { uploaded: todo.length, packs }
|
|
423
|
+
return { uploaded: todo.length + chunked, packs, ...(chunked ? { chunked } : {}) }
|
|
137
424
|
}
|
|
138
425
|
}
|
|
139
426
|
}
|