@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.
Files changed (42) hide show
  1. package/bin/seam.js +118 -17
  2. package/lib/quilts/agent-tokens.js +148 -0
  3. package/lib/quilts/archiver.js +241 -0
  4. package/lib/quilts/attest.js +212 -0
  5. package/lib/quilts/bisect-run.js +3 -2
  6. package/lib/quilts/block.js +497 -55
  7. package/lib/quilts/cas.js +78 -0
  8. package/lib/quilts/cbor.js +2 -0
  9. package/lib/quilts/claims.js +409 -0
  10. package/lib/quilts/cli.js +612 -69
  11. package/lib/quilts/client.js +306 -19
  12. package/lib/quilts/conflict-format.js +266 -0
  13. package/lib/quilts/daemon.js +174 -0
  14. package/lib/quilts/describe.js +296 -38
  15. package/lib/quilts/dict.js +34 -0
  16. package/lib/quilts/exit-archive.js +184 -0
  17. package/lib/quilts/export-format.js +52 -0
  18. package/lib/quilts/export-git.js +27 -3
  19. package/lib/quilts/export-lfs.js +52 -0
  20. package/lib/quilts/export-sign.js +49 -0
  21. package/lib/quilts/fastcdc.js +186 -0
  22. package/lib/quilts/hash.js +17 -2
  23. package/lib/quilts/ignored.js +46 -0
  24. package/lib/quilts/import-git.js +54 -2
  25. package/lib/quilts/journal.js +105 -0
  26. package/lib/quilts/limits.js +197 -0
  27. package/lib/quilts/linepolicy.js +109 -0
  28. package/lib/quilts/pack.js +146 -18
  29. package/lib/quilts/persona.js +70 -0
  30. package/lib/quilts/records.js +7 -3
  31. package/lib/quilts/recover.js +37 -0
  32. package/lib/quilts/runner.js +79 -12
  33. package/lib/quilts/sealed.js +231 -0
  34. package/lib/quilts/secrets.js +86 -0
  35. package/lib/quilts/signing.js +7 -3
  36. package/lib/quilts/tarball.js +107 -0
  37. package/lib/quilts/textdiff.js +7 -5
  38. package/lib/quilts/tools.js +404 -83
  39. package/lib/quilts/tree.js +351 -0
  40. package/lib/quilts/want.js +51 -0
  41. package/lib/quilts/webhook.js +116 -0
  42. package/package.json +3 -2
@@ -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
- export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch, timeoutMs = 120_000 }) {
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 quilt server: set QUILT_API (or sign in with `quilt login`).')
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, { raw = false } = {}) {
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 ? `?path=${enc(p)}` : ''}`)).resolutions,
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
- tree: (q, rev, paths) => call('GET', `${Q(q)}/tree/${enc(rev || 'main')}${qs({ paths })}`),
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
- blame: (q, rev, path) => call('GET', `${Q(q)}/blame/${enc(rev || 'main')}/${pathPart(path)}`),
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 ([{ kind, id, bytes }]), in packs of at most
123
- * ~48 MiB, after asking `have` (04: want-only, one pack per commit for a typical commit).
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 missing = new Set()
127
- for (let i = 0; i < objs.length; i += 10000) for (const id of (await call('POST', `${Q(q)}/have`, { ids: objs.slice(i, i + 10000).map((o) => o.id) })).missing) missing.add(id)
128
- const todo = objs.filter((o) => missing.has(o.id))
129
- let batch = []; let size = 0; let packs = 0
130
- const flush = async () => { if (!batch.length) return; await call('POST', `${Q(q)}/packs`, buildPack(batch).bytes, { raw: true }); packs++; batch = []; size = 0 }
131
- for (const o of todo) {
132
- if (size + o.bytes.length > 48 * 1024 * 1024) await flush()
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
  }