@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.
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 +623 -69
  11. package/lib/quilts/client.js +307 -19
  12. package/lib/quilts/conflict-format.js +266 -0
  13. package/lib/quilts/daemon.js +174 -0
  14. package/lib/quilts/describe.js +281 -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 +412 -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
@@ -6,48 +6,147 @@
6
6
  // trailer, so it names every entry's location and id.
7
7
  import zlib from 'node:zlib'
8
8
  import { idOf } from './hash.js'
9
+ import { BUILTIN_DICT } from './dict.js'
9
10
 
10
11
  export const MAGIC = Buffer.from('QPK1')
11
12
  export const MAX_PACK = 64 * 1024 * 1024
12
13
 
13
14
  /** Entry kinds, and the id context each one's (decompressed) bytes hash under. */
14
- export const KIND = { blob: 1, delta: 2, chunked: 3, chunk: 4, manifest: 5, tree: 6, term: 7, commit: 8 }
15
+ export const KIND = { blob: 1, delta: 2, chunked: 3, chunk: 4, manifest: 5, tree: 6, term: 7, commit: 8, dict: 9 }
15
16
  const NAME = Object.fromEntries(Object.entries(KIND).map(([k, v]) => [v, k]))
16
- const CONTEXT = { blob: 'blob', delta: 'blob', chunked: 'blob', chunk: 'chunk', manifest: 'manifest', tree: 'tree', term: 'term', commit: 'commit' }
17
+ const CONTEXT = { blob: 'blob', delta: 'blob', chunked: 'blob', chunk: 'chunk', manifest: 'manifest', tree: 'tree', term: 'term', commit: 'commit', dict: 'dict' }
17
18
 
18
19
  // Compression, in the entry's flags. zstd when this Node has it (≥ 22.15), else deflate.
19
20
  export const RAW = 0
20
21
  export const DEFLATE = 1
21
22
  export const ZSTD = 2
22
- const HAS_ZSTD = typeof zlib.zstdCompressSync === 'function'
23
+ // The same, with the built-in dictionary (dict.js): only a blob of at most 64 KiB, and only when
24
+ // the seal asks for it (SEAM_DICTIONARY). Every reader reads both, whatever the setting.
25
+ export const DEFLATE_DICT = 3
26
+ export const ZSTD_DICT = 4
27
+ // The same, with a dictionary trained for the quilt (03 Dictionaries; dict-train.js): the stored
28
+ // bytes are the dictionary's id (32 bytes, as a delta names its base), then the compressed blob. The
29
+ // reader resolves the dictionary by that id, never by the quilt's current one. Only the server's seal
30
+ // writes them (SEAM_TRAINED_DICTIONARY); every reader reads them.
31
+ export const DEFLATE_TRAINED = 5
32
+ export const ZSTD_TRAINED = 6
33
+ const DICT_MAX = 64 * 1024
34
+ /** In a blob row's flags (not a pack's): the row names a chunked file's root manifest (10). */
35
+ export const CHUNKED = 8
36
+ /** In a tree row's flags (not a pack's): a node a lazy derivation wrote, so a GC candidate (03 Bounded marking). */
37
+ export const UNPINNED = 16
38
+ /** In a blob row's flags (not a pack's): the row names a delta entry; the row's base_id is its base (03 The background squeeze). */
39
+ export const DELTA = 32
40
+ export const HAS_ZSTD = typeof zlib.zstdCompressSync === 'function'
23
41
 
24
- function squeeze (bytes) {
42
+ /** `bytes` compressed with `dict` as zstd's dictionary or deflate's preset window. */
43
+ const withDictionary = (bytes, dict, zstd = HAS_ZSTD) => zstd ? zlib.zstdCompressSync(bytes, { dictionary: dict }) : zlib.deflateRawSync(bytes, { level: 6, dictionary: dict })
44
+
45
+ /** Whether entry flags name a trained dictionary (the stored bytes then start with its id). */
46
+ export const usesTrained = (flags) => flags === DEFLATE_TRAINED || flags === ZSTD_TRAINED
47
+
48
+ /** The id of the trained dictionary an entry's stored bytes name (flags 5 and 6). */
49
+ export function trainedDictOf (stored) {
50
+ if (!stored || stored.length < 32) throw Object.assign(new Error('a dictionary entry is shorter than its dictionary id'), { code: 'pack_corrupt' })
51
+ return Buffer.from(stored.subarray(0, 32)).toString('hex')
52
+ }
53
+
54
+ /** { flags, data }: `bytes` with a trained dictionary ({ id, bytes }), its id first. `zstd` forces the path. */
55
+ export function encodeTrained (bytes, dict, { zstd = HAS_ZSTD } = {}) {
56
+ if (zstd && !HAS_ZSTD) throw new Error('this Node cannot write zstd (needs Node 22.15 or later)')
57
+ return { flags: zstd ? ZSTD_TRAINED : DEFLATE_TRAINED, data: Buffer.concat([Buffer.from(dict.id, 'hex'), withDictionary(bytes, dict.bytes, zstd)]) }
58
+ }
59
+
60
+ function squeeze (bytes, dict = false, trained = null) {
25
61
  if (bytes.length < 64) return { flags: RAW, data: bytes }
26
- const data = HAS_ZSTD ? zlib.zstdCompressSync(bytes) : zlib.deflateRawSync(bytes, { level: 6 })
62
+ let flags = HAS_ZSTD ? ZSTD : DEFLATE
63
+ let data = HAS_ZSTD ? zlib.zstdCompressSync(bytes) : zlib.deflateRawSync(bytes, { level: 6 })
64
+ if (dict && bytes.length <= DICT_MAX) {
65
+ const withDict = withDictionary(bytes, BUILTIN_DICT)
66
+ if (withDict.length < data.length) { data = withDict; flags = HAS_ZSTD ? ZSTD_DICT : DEFLATE_DICT }
67
+ // The quilt's own dictionary, where smaller still, its 32-byte id counted (03 Dictionaries).
68
+ if (trained?.bytes?.length) {
69
+ const t = encodeTrained(bytes, trained)
70
+ if (t.data.length < data.length) ({ data, flags } = t)
71
+ }
72
+ }
27
73
  // Stored raw unless compression saves at least 5% (03's rule for chunks, used for everything).
28
74
  if (data.length > bytes.length * 0.95) return { flags: RAW, data: bytes }
29
- return { flags: HAS_ZSTD ? ZSTD : DEFLATE, data }
75
+ return { flags, data }
30
76
  }
31
77
 
32
- /** The bytes of a stored entry, decompressed by its flags. */
33
- export function unsqueeze (flags, data) {
78
+ // The allocation bound (15 "Fuzzers"): an entry does not declare its decompressed size, so every
79
+ // decompression stops at MAX_PACK, which no entry of a sealed pack can exceed (a pack is at most
80
+ // MAX_PACK of entries, each its own plaintext or smaller). Past it, zlib throws ERR_BUFFER_TOO_LARGE
81
+ // before allocating more, and readEntry reports the entry as damage.
82
+ const BOUND = { maxOutputLength: MAX_PACK }
83
+
84
+ /**
85
+ * The bytes of a stored entry, decompressed by its flags. `dict`: the bytes of the trained
86
+ * dictionary the entry names (flags 5 and 6; trainedDictOf), which the caller resolves by id.
87
+ */
88
+ export function unsqueeze (flags, data, dict = null) {
34
89
  if (flags === RAW) return Buffer.from(data)
35
- if (flags === DEFLATE) return zlib.inflateRawSync(data)
36
- if (flags === ZSTD) {
90
+ if (usesTrained(flags)) {
91
+ if (!dict) throw new Error(`this entry needs dictionary ${trainedDictOf(data).slice(0, 12)}, which was not given`)
92
+ const body = data.subarray(32)
93
+ if (flags === DEFLATE_TRAINED) return zlib.inflateRawSync(body, { ...BOUND, dictionary: dict })
37
94
  if (!HAS_ZSTD) throw new Error('this pack entry is zstd and this Node cannot read zstd (needs Node 22.15 or later)')
38
- return zlib.zstdDecompressSync(data)
95
+ return zlib.zstdDecompressSync(body, { ...BOUND, dictionary: dict })
96
+ }
97
+ if (flags === DEFLATE) return zlib.inflateRawSync(data, BOUND)
98
+ if (flags === DEFLATE_DICT) return zlib.inflateRawSync(data, { ...BOUND, dictionary: BUILTIN_DICT })
99
+ if (flags === ZSTD || flags === ZSTD_DICT) {
100
+ if (!HAS_ZSTD) throw new Error('this pack entry is zstd and this Node cannot read zstd (needs Node 22.15 or later)')
101
+ return flags === ZSTD ? zlib.zstdDecompressSync(data, BOUND) : zlib.zstdDecompressSync(data, { ...BOUND, dictionary: BUILTIN_DICT })
39
102
  }
40
103
  throw new Error(`unknown pack entry flags ${flags}`)
41
104
  }
42
105
 
106
+ // Deltas (03 Blob encodings, written only by the squeeze): an entry of kind `delta` holds the
107
+ // base blob's id (32 bytes), then the target compressed with the base's plaintext as the
108
+ // dictionary. Its flags say how: ZSTD (zstd with { dictionary: base }) or DEFLATE (deflate-raw with
109
+ // the base as its preset dictionary, of which deflate uses the last 32 KiB). The id an entry is
110
+ // stored under is the target's blob id, checked after it is rebuilt.
111
+
112
+ /** The base blob id a delta entry's stored bytes name. */
113
+ export function deltaBase (stored) {
114
+ if (!stored || stored.length < 32) throw Object.assign(new Error('a delta entry is shorter than its base id'), { code: 'pack_corrupt' })
115
+ return Buffer.from(stored.subarray(0, 32)).toString('hex')
116
+ }
117
+
118
+ /** { flags, data }: `target` as a delta against `base` (plaintext), whose blob id is `baseId`. `zstd` forces the path (default: zstd where this Node has it). */
119
+ export function encodeDelta (baseId, base, target, { zstd = HAS_ZSTD } = {}) {
120
+ if (zstd && !HAS_ZSTD) throw new Error('this Node cannot write zstd (needs Node 22.15 or later)')
121
+ const body = zstd
122
+ ? zlib.zstdCompressSync(target, { dictionary: base, params: { [zlib.constants.ZSTD_c_compressionLevel]: 19 } })
123
+ : zlib.deflateRawSync(target, { level: 9, dictionary: base.length ? base : undefined })
124
+ return { flags: zstd ? ZSTD : DEFLATE, data: Buffer.concat([Buffer.from(baseId, 'hex'), body]) }
125
+ }
126
+
127
+ /** The target bytes of a delta entry (`stored`, base id included), rebuilt against the base's plaintext. */
128
+ export function decodeDelta (flags, stored, base) {
129
+ const body = stored.subarray(32)
130
+ if (flags === DEFLATE) return zlib.inflateRawSync(body, base.length ? { ...BOUND, dictionary: base } : BOUND)
131
+ if (flags === ZSTD) {
132
+ if (!HAS_ZSTD) throw new Error('this delta entry is zstd and this Node cannot read zstd (needs Node 22.15 or later)')
133
+ return zlib.zstdDecompressSync(body, { ...BOUND, dictionary: base })
134
+ }
135
+ throw new Error(`unknown delta entry flags ${flags}`)
136
+ }
137
+
43
138
  const ENTRY_HEAD = 1 + 32 + 4
44
139
  const INDEX_ENTRY = 1 + 32 + 4 + 4 + 1
45
140
 
46
141
  /**
47
142
  * Builds a pack from [{ kind: 'blob' | 'tree' | 'term' | 'commit' | ..., id, bytes }]
48
143
  * (duplicate ids are written once). Returns { id, bytes, index: [{ kind, id, offset, len, flags }] }.
144
+ * `dict`: compress small blobs with the built-in dictionary where that is smaller (SEAM_DICTIONARY);
145
+ * `trained` ({ id, bytes }, with `dict`): also with the quilt's trained dictionary where smaller still.
146
+ * An entry given as { kind, id, stored, flags } is written as those stored bytes, unchanged (a
147
+ * delta, or an entry carried over from another pack).
49
148
  */
50
- export function buildPack (objects) {
149
+ export function buildPack (objects, { dict = false, trained = null } = {}) {
51
150
  const parts = [MAGIC]
52
151
  let at = MAGIC.length
53
152
  const index = []
@@ -57,7 +156,10 @@ export function buildPack (objects) {
57
156
  seen.add(o.id)
58
157
  const kind = KIND[o.kind]
59
158
  if (!kind) throw new Error(`unknown pack entry kind ${o.kind}`)
60
- const { flags, data } = squeeze(Buffer.from(o.bytes))
159
+ // The write side of the allocation bound (unsqueeze): no entry is over MAX_PACK as plaintext,
160
+ // so every reader can stop there. A larger file is stored in chunks (10, SEAM_CHUNKING=1).
161
+ if (!o.stored && o.bytes.length > MAX_PACK) throw Object.assign(new Error(`${o.kind} ${String(o.id).slice(0, 12)} is ${o.bytes.length} bytes whole, over 64 MiB: store it in chunks (SEAM_CHUNKING=1)`), { code: 'pack_too_large' })
162
+ const { flags, data } = o.stored ? { flags: o.flags, data: Buffer.from(o.stored) } : squeeze(Buffer.from(o.bytes), dict && o.kind === 'blob', o.kind === 'blob' ? trained : null)
61
163
  const h = Buffer.alloc(ENTRY_HEAD)
62
164
  h[0] = kind; Buffer.from(o.id, 'hex').copy(h, 1); h.writeUInt32BE(data.length, 33)
63
165
  parts.push(h, data)
@@ -88,7 +190,10 @@ function trailerBytes (index) {
88
190
  export function readIndex (bytes) {
89
191
  const b = Buffer.from(bytes)
90
192
  if (!b.subarray(0, 4).equals(MAGIC)) throw new Error('not a Quilt pack')
193
+ // A pack cut short or with a trailer length past its own bytes is damaged, not a crash (15).
194
+ if (b.length < MAGIC.length + 8) throw new Error('pack trailer is damaged')
91
195
  const tlen = b.readUInt32BE(b.length - 4)
196
+ if (tlen < 8 || tlen > b.length - MAGIC.length) throw new Error('pack trailer is damaged')
92
197
  const t = b.subarray(b.length - tlen)
93
198
  const n = t.readUInt32BE(0)
94
199
  if (tlen !== 4 + n * INDEX_ENTRY + 4) throw new Error('pack trailer is damaged')
@@ -99,9 +204,27 @@ export function readIndex (bytes) {
99
204
  return { id: idOf('pack', t), index }
100
205
  }
101
206
 
102
- /** The object `e` (an index entry) of a pack, from its stored bytes, checked against its id. */
103
- export function readEntry (e, stored) {
104
- const bytes = unsqueeze(e.flags, stored)
207
+ /**
208
+ * The object `e` (an index entry) of a pack, from its stored bytes, checked against its id.
209
+ * `sealed`: a sealed quilt's blob (11.11), whose keyed id only a key holder can check.
210
+ * `base`: a delta entry's base plaintext (a delta cannot be read without it). `dict`: the trained
211
+ * dictionary's bytes for an entry of flags 5 or 6 (resolved by the id its stored bytes start with).
212
+ */
213
+ export function readEntry (e, stored, { sealed = false, base = null, dict = null } = {}) {
214
+ let bytes
215
+ if (e.kind === 'delta') {
216
+ if (!base) throw Object.assign(new Error(`pack entry ${e.id} is a delta and its base was not given`), { code: 'pack_corrupt', id: e.id })
217
+ try { bytes = decodeDelta(e.flags, stored, base) } catch (err) { throw Object.assign(new Error(`pack entry ${e.id} does not decompress: ${err.message}`), { code: 'pack_corrupt', id: e.id }) }
218
+ if (idOf('blob', bytes) !== e.id) throw Object.assign(new Error(`pack entry ${e.id} does not match its id`), { code: 'pack_corrupt', id: e.id })
219
+ return bytes
220
+ }
221
+ // Bytes damaged on the way back can fail to decompress before any id check: that is the same
222
+ // corruption (15's checksum path), so it carries the same code and the reader reads once more.
223
+ try { bytes = unsqueeze(e.flags, stored, dict) } catch (err) { throw Object.assign(new Error(`pack entry ${e.id} does not decompress: ${err.message}`), { code: 'pack_corrupt', id: e.id }) }
224
+ if (sealed && e.kind === 'blob') return bytes
225
+ // A chunked entry is the root manifest under the file's id: only reassembly can check that id,
226
+ // which the reader (readChunked) and the seal of a client pack (uploadPack) do.
227
+ if (e.kind === 'chunked') return bytes
105
228
  if (idOf(CONTEXT[e.kind], bytes) !== e.id) throw Object.assign(new Error(`pack entry ${e.id} does not match its id`), { code: 'pack_corrupt', id: e.id })
106
229
  return bytes
107
230
  }
@@ -109,10 +232,15 @@ export function readEntry (e, stored) {
109
232
  /**
110
233
  * What sealing checks before any row is written (03, "Sealing a pack"): the pack id is the
111
234
  * trailer's, and every entry decompresses to bytes whose id is the one claimed. Returns the index.
235
+ * `bases`: base id → plaintext, for the pack's delta entries (a delta with no base given fails).
236
+ * `dicts`: dictionary id → bytes, for entries with a trained dictionary (one not given fails).
112
237
  */
113
- export function verifyPack (bytes, expectId) {
238
+ export function verifyPack (bytes, expectId, { sealed = false, bases = null, dicts = null } = {}) {
114
239
  const { id, index } = readIndex(bytes)
115
240
  if (expectId && id !== expectId) throw Object.assign(new Error('pack id does not match its trailer'), { code: 'pack_corrupt' })
116
- for (const e of index) readEntry(e, bytes.subarray(e.offset, e.offset + e.len))
241
+ for (const e of index) {
242
+ const stored = bytes.subarray(e.offset, e.offset + e.len)
243
+ readEntry(e, stored, { sealed, base: e.kind === 'delta' ? bases?.get(deltaBase(stored)) || null : null, dict: usesTrained(e.flags) && stored.length >= 32 ? dicts?.get(trainedDictOf(stored)) || null : null })
244
+ }
117
245
  return { id, index }
118
246
  }
@@ -0,0 +1,70 @@
1
+ // Local AI tools as agent members of their own (plans/quilt-source-control/11.5; TODO 3.8). With
2
+ // `seam mcp --as-agent` (or SEAM_MCP_AGENT=1) each AI tool that connects is one of the signed-in
3
+ // person's agents, named from its MCP `initialize` client name, and its seam_* calls carry an agent
4
+ // token (sat_, agent-tokens.js) minted for it: `write` (or `merge` with --merge), for the block's
5
+ // square (or its own squares outside a block), 8 hours. So the agent fences hold for it (token
6
+ // caps, human-only paths, `agents` rows) and its commits name it, working for the person.
7
+ // The secrets stay in <seam home>/personas.json (0600), renewed when under 15 minutes are left or
8
+ // the block moves to another square; the token they replace is revoked.
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import crypto from 'node:crypto'
12
+
13
+ const TTL_S = 8 * 60 * 60
14
+ const RENEW_MS = 15 * 60 * 1000
15
+
16
+ /** The agent's name for an MCP client name: printable, up to 80 characters; a tool that gives none is `ai-tool`. */
17
+ export function personaName (clientName) {
18
+ const n = String(clientName || '').replace(/\s+/g, ' ').replace(/[^\w .@~+-]/g, '').trim().slice(0, 80).trim()
19
+ return n || 'ai-tool'
20
+ }
21
+
22
+ /**
23
+ * A function from { tool, quilt, square? } to { token, agent, id, expires_at }, for the person
24
+ * `client` signs in as (it needs agents, createAgent, createAgentToken, revokeAgentToken).
25
+ */
26
+ export function personaTokens ({ client, home, merge = false, now = Date.now }) {
27
+ const file = path.join(home, 'personas.json')
28
+ const level = merge ? 'merge' : 'write'
29
+ const read = () => { try { return JSON.parse(fs.readFileSync(file, 'utf8')) || {} } catch { return {} } }
30
+ const write = (all) => {
31
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 })
32
+ if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new Error(`Refusing to write ${file}: it's a symlink`)
33
+ const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`
34
+ fs.writeFileSync(tmp, JSON.stringify(all, null, 2), { mode: 0o600, flag: 'wx' })
35
+ fs.renameSync(tmp, file)
36
+ }
37
+ const flight = new Map()
38
+
39
+ async function agentFor (name) {
40
+ const { agents } = await client.agents()
41
+ const a = agents.find((x) => x.name === name && !x.revoked_at)
42
+ return a ? a.member_id : (await client.createAgent({ name })).member_id
43
+ }
44
+
45
+ async function mint ({ tool, quilt, square }) {
46
+ const name = personaName(tool)
47
+ const key = `${quilt}|${name}|${level}`
48
+ const all = read()
49
+ const hit = all[key]
50
+ if (hit && (hit.square || null) === (square || null) && hit.expires_at - now() > RENEW_MS) return hit
51
+ // The agent is looked up again on each mint, so one its person revoked is not reused.
52
+ const agent = await agentFor(name)
53
+ const t = await client.createAgentToken(quilt, { agent, squares: square ? [square] : 'own', max_level: level, ttl_s: TTL_S })
54
+ const entry = { token: t.token, id: t.id, agent, square: square || null, expires_at: t.expires_at }
55
+ const next = Object.fromEntries(Object.entries(read()).filter(([, v]) => v.expires_at > now()))
56
+ next[key] = entry
57
+ write(next)
58
+ if (hit && hit.expires_at > now()) await client.revokeAgentToken(quilt, hit.id).catch(() => {})
59
+ return entry
60
+ }
61
+
62
+ // One mint at a time per tool and quilt: parallel tool calls share it.
63
+ return function tokenFor (want) {
64
+ const k = `${want.quilt}|${personaName(want.tool)}`
65
+ const prev = flight.get(k) || Promise.resolve()
66
+ const run = prev.catch(() => {}).then(() => mint(want))
67
+ flight.set(k, run)
68
+ return run
69
+ }
70
+ }
@@ -42,7 +42,9 @@ function side (s) {
42
42
  ...(s.mode ? { m: s.mode } : {}),
43
43
  ...(s.from_commit ? { fc: idBytes(s.from_commit) } : {}),
44
44
  ...(s.from_square ? { fs: s.from_square } : {}),
45
- ...(s.by ? { by: s.by } : {})
45
+ ...(s.by ? { by: s.by } : {}),
46
+ // Where a side has the file when the sides disagree on its path (two renames of one file, 07).
47
+ ...(s.path ? { p: s.path } : {})
46
48
  }
47
49
  }
48
50
  const unside = (s) => ({
@@ -50,7 +52,8 @@ const unside = (s) => ({
50
52
  ...(s.m ? { mode: s.m } : {}),
51
53
  ...(s.fc ? { from_commit: hex(s.fc) } : {}),
52
54
  ...(s.fs ? { from_square: s.fs } : {}),
53
- ...(s.by ? { by: s.by } : {})
55
+ ...(s.by ? { by: s.by } : {}),
56
+ ...(s.p ? { path: s.p } : {})
54
57
  })
55
58
 
56
59
  /** A conflict term's canonical bytes (kind, N adds, N−1 removes, origin). */
@@ -147,7 +150,8 @@ export function cleanCommit (c) {
147
150
  if (Buffer.byteLength(message) > MAX_MESSAGE) throw new Error('commit message over 64 KiB')
148
151
  const changes = cleanChanges(c.changes || [])
149
152
  const stack = {}
150
- for (const [k, v] of Object.entries(c.stack || {})) if (v != null) stack[k] = String(v)
153
+ // null is kept: it hands the key back to derivation from this commit on (10 rule 2).
154
+ for (const [k, v] of Object.entries(c.stack || {})) if (v !== undefined) stack[k] = v === null ? null : String(v)
151
155
  return {
152
156
  quilt_id: String(c.quilt_id),
153
157
  square_id: String(c.square_id),
@@ -0,0 +1,37 @@
1
+ // recover-gap (plans/quilt-source-control/03 and 15, "Backups and restore drills"): every commit
2
+ // record is in a sealed pack before its row commits, so commits made after the last WAL segment
3
+ // (whose `commit` rows the tip journal does not carry) come back from the packs alone. It lists
4
+ // the quilt's packs, reads each trailer's index, decodes every `commit` entry, and returns the
5
+ // ones replay did not already rebuild, parents before children.
6
+ //
7
+ // Their `seq` is not in any pack: 03 takes it from the relays' event hour, which Seam does not
8
+ // keep, so recovered commits come back with `seq: null` and the order above (ruling 2026-10-26,
9
+ // recorded in 03). A merge row in the journal still places each merged commit on its line.
10
+ import { readIndex, readEntry } from './pack.js'
11
+ import { decodeCommit } from './records.js'
12
+
13
+ export async function recoverGap (objects, q, known = new Map()) {
14
+ const found = new Map()
15
+ for (const packId of await objects.listPacks(q)) {
16
+ const bytes = await objects.readPack(q, packId)
17
+ const { index } = readIndex(bytes)
18
+ for (const e of index) {
19
+ if (e.kind !== 'commit' || known.has(e.id) || found.has(e.id)) continue
20
+ const record = decodeCommit(readEntry(e, bytes.subarray(e.offset, e.offset + e.len)))
21
+ if (record.quilt_id !== q) continue
22
+ found.set(e.id, { id: e.id, ...record, seq: null, pack: packId })
23
+ }
24
+ }
25
+ // Parents (and predecessors) first, then by authored time; ids break ties so the order is stable.
26
+ const out = []
27
+ const placed = new Set()
28
+ const visit = (c, path = new Set()) => {
29
+ if (placed.has(c.id) || path.has(c.id)) return
30
+ path.add(c.id)
31
+ for (const p of [...c.parents, c.predecessor].filter(Boolean)) if (found.has(p)) visit(found.get(p), path)
32
+ placed.add(c.id)
33
+ out.push(c)
34
+ }
35
+ for (const c of [...found.values()].sort((a, b) => a.authored_at - b.authored_at || (a.id < b.id ? -1 : 1))) visit(c)
36
+ return out
37
+ }
@@ -6,8 +6,10 @@
6
6
  import fs from 'node:fs'
7
7
  import os from 'node:os'
8
8
  import path from 'node:path'
9
+ import http from 'node:http'
9
10
  import { spawn } from 'node:child_process'
10
11
  import { checkout } from './block.js'
12
+ import { verifyWebhook } from './webhook.js'
11
13
 
12
14
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
13
15
  const safe = (key) => key.replace(/[^\w.-]+/g, '_').slice(0, 180)
@@ -19,10 +21,14 @@ export function logLink (template, runKey, file) {
19
21
  }
20
22
 
21
23
  /** The environment a run's command gets: what to run and, when Seam knows, which tests the change reaches. */
24
+ // The runner's own sign-in to Seam: a check runs the patch's code, which never gets it (its tests
25
+ // would reach the real server with the runner's key, and any patch could read the key).
26
+ const RUNNER_ONLY = new Set(['SEAM_TOKEN', 'SEAM_API_URL', 'SEAM_QUILT', 'SEAM_WEBHOOK_SECRET', 'SEAM_HOME'])
27
+
22
28
  export function runEnv (run, base = process.env) {
23
29
  const a = run.affected || { confidence: 'all' }
24
30
  return {
25
- ...base,
31
+ ...Object.fromEntries(Object.entries(base).filter(([k]) => !RUNNER_ONLY.has(k))),
26
32
  SEAM_CHECK: run.name, SEAM_TIER: run.tier, SEAM_COMMIT: run.commit, SEAM_RUN_KEY: run.run_key,
27
33
  SEAM_LINE: run.line || '', SEAM_SQUARE: run.square || '',
28
34
  SEAM_AFFECTED_CONFIDENCE: a.confidence, SEAM_AFFECTED_TESTS: a.confidence === 'all' ? '' : (a.tests || []).join('\n')
@@ -30,27 +36,58 @@ export function runEnv (run, base = process.env) {
30
36
  }
31
37
 
32
38
  /**
33
- * Runs checks for one quilt. { client, quilt, checks: ['ci'], run: 'npm test', dir (where block
39
+ * A runner config file: { quilt?, checks: { "<check>": "<command>", … } }, so one runner runs
40
+ * each check with its own command (CI and each deploy, say).
41
+ */
42
+ export function readRunnerConfig (text) {
43
+ let c
44
+ try { c = JSON.parse(text) } catch (err) { throw new Error(`seam runner --config: not JSON (${err.message})`) }
45
+ const checks = c && typeof c.checks === 'object' && !Array.isArray(c.checks) ? c.checks : null
46
+ if (!checks || !Object.keys(checks).length || Object.values(checks).some((v) => typeof v !== 'string' || !v.trim())) throw new Error('seam runner --config: { "checks": { "<check>": "<command>" } }')
47
+ return { quilt: typeof c.quilt === 'string' ? c.quilt : null, commands: checks }
48
+ }
49
+
50
+ /**
51
+ * Runs checks for one quilt. { client, quilt, checks: ['ci'], run: 'npm test' (or commands: { ci:
52
+ * 'npm test', deploy: '…' }, one command per check), dir (where block
34
53
  * folders and logs go), api, once (handle what is queued now, then return), intervalMs, logUrl,
35
54
  * say(line) }. Returns the runs it finished: [{ run_key, status | 'cancelled', url }].
36
55
  */
37
- export async function runChecks ({ client, quilt, checks, run: command, dir = path.join(os.tmpdir(), 'seam-runner'), api, once = false, intervalMs = 5000, logUrl, say = () => {}, keep = false }) {
56
+ export async function runChecks ({ client, quilt, checks, run: command, commands = null, dir = path.join(os.tmpdir(), 'seam-runner'), api, once = false, intervalMs = 5000, logUrl, say = () => {}, keep = false, wake = null, idleExitMs = 0, retryMs = 1000 }) {
38
57
  if (!checks?.length) throw new Error('seam runner needs --check <name>[,<name>]')
39
- if (!command) throw new Error('seam runner needs --run "<command>"')
58
+ if (!command && !commands) throw new Error('seam runner needs --run "<command>" or --config <file>')
59
+ const commandOf = (name) => commands?.[name] || command
60
+ // Seam restarting (a deploy of Seam itself, run by this runner) answers 5xx or drops the
61
+ // connection for a moment: a call to it waits and tries again, for about a minute and a half.
62
+ const patiently = async (fn) => {
63
+ for (let i = 1; ; i++) {
64
+ try { return await fn() } catch (err) {
65
+ if (i >= 8 || (err.status && err.status < 500 && err.status !== 429)) throw err
66
+ await sleep(Math.min(retryMs * 2 ** (i - 1), retryMs * 15))
67
+ }
68
+ }
69
+ }
40
70
  const logs = path.join(dir, 'logs')
41
71
  fs.mkdirSync(logs, { recursive: true })
42
72
  const done = []
43
73
  const seen = new Set()
74
+ let lastWork = Date.now()
44
75
  for (;;) {
45
76
  const queued = []
46
- for (const name of checks) queued.push(...(await client.checkRuns(quilt, { state: 'queued', name })).runs)
77
+ for (const name of checks) queued.push(...(await patiently(() => client.checkRuns(quilt, { state: 'queued', name }))).runs)
47
78
  const todo = queued.filter((r) => !seen.has(r.run_key)).sort((a, b) => a.at - b.at)
48
79
  for (const r of todo) {
49
80
  seen.add(r.run_key)
50
81
  done.push(await runOne(r))
82
+ lastWork = Date.now()
51
83
  }
52
84
  if (once) return done
53
- await sleep(intervalMs)
85
+ // --idle-exit: nothing to do for that long, so stop (a stopped machine costs nothing; Seam's
86
+ // webhook starts it again on the next request).
87
+ if (idleExitMs && !todo.length && Date.now() - lastWork >= idleExitMs) return done
88
+ const nap = idleExitMs ? Math.max(50, Math.min(intervalMs, idleExitMs - (Date.now() - lastWork))) : intervalMs
89
+ // A wake (Seam's webhook) counts as work coming: the idle clock starts again.
90
+ if (wake) { if (await Promise.race([sleep(nap).then(() => false), wake().then(() => true)])) lastWork = Date.now() } else await sleep(nap)
54
91
  }
55
92
 
56
93
  async function runOne (r) {
@@ -59,17 +96,21 @@ export async function runChecks ({ client, quilt, checks, run: command, dir = pa
59
96
  const url = logLink(logUrl, r.run_key, file)
60
97
  say(`${r.name} (${r.tier}) on ${r.commit.slice(0, 12)}${r.square ? ` in ${r.square}` : ` at ${r.line}`}: running`)
61
98
  const out = fs.openSync(file, 'w')
99
+ const cmd = commandOf(r.name)
62
100
  let status
63
101
  try {
64
- fs.writeSync(out, `seam runner: ${r.name} ${r.tier} ${r.commit} run_key ${r.run_key}\n$ ${command}\n`)
65
- await checkout(client, { quilt, rev: r.commit, dir: work, api })
66
- const child = spawn(command, { cwd: work, shell: true, env: runEnv(r), stdio: ['ignore', out, out], detached: process.platform !== 'win32' })
102
+ fs.writeSync(out, `seam runner: ${r.name} ${r.tier} ${r.commit} run_key ${r.run_key}\n$ ${cmd}\n`)
103
+ // The checkout waits out a restart too (a deploy of Seam landing as a check starts), each
104
+ // try into an empty folder.
105
+ await patiently(async () => { fs.rmSync(work, { recursive: true, force: true }); fs.mkdirSync(work, { recursive: true }); return checkout(client, { quilt, rev: r.commit, dir: work, api }) })
106
+ const child = spawn(cmd, { cwd: work, shell: true, env: runEnv(r), stdio: ['ignore', out, out], detached: process.platform !== 'win32' })
67
107
  const exited = new Promise((resolve) => child.on('close', (code) => resolve(code)))
68
108
  let cancelled = false
69
109
  for (;;) {
70
110
  const code = await Promise.race([exited, sleep(Math.min(intervalMs, 2000)).then(() => undefined)])
71
111
  if (code !== undefined) { status = code === 0 ? 'green' : 'red'; break }
72
- const gone = (await client.checkRuns(quilt, { state: 'cancelled', name: r.name, limit: 50 })).runs.some((x) => x.run_key === r.run_key)
112
+ // Seam not answering just now is no cancellation: the next look asks again.
113
+ const gone = await client.checkRuns(quilt, { state: 'cancelled', name: r.name, limit: 50 }).then((x) => x.runs.some((y) => y.run_key === r.run_key), () => false)
73
114
  if (gone && !cancelled) {
74
115
  cancelled = true
75
116
  try { process.kill(process.platform !== 'win32' ? -child.pid : child.pid, 'SIGTERM') } catch { child.kill('SIGTERM') }
@@ -86,10 +127,36 @@ export async function runChecks ({ client, quilt, checks, run: command, dir = pa
86
127
  }
87
128
  if (status !== 'cancelled') {
88
129
  const body = { check: r.name, status, run_key: r.run_key, url }
89
- if (r.tier === 'pre_merge' && r.square) await client.reportSquareCheck(quilt, r.square, { ...body, head: r.commit })
90
- else await client.reportCheck(quilt, r.line, body)
130
+ if (r.tier === 'pre_merge' && r.square) await patiently(() => client.reportSquareCheck(quilt, r.square, { ...body, head: r.commit }))
131
+ else await patiently(() => client.reportCheck(quilt, r.line, body))
91
132
  }
92
133
  say(`${r.name} on ${r.commit.slice(0, 12)}: ${status} (log ${url})`)
93
134
  return { run_key: r.run_key, status, url }
94
135
  }
95
136
  }
137
+
138
+ /**
139
+ * `seam runner --listen <port>`: an HTTP server Seam's webhook (seam events --webhook) POSTs
140
+ * check.requested to, so the runner starts at once instead of at its next poll, and a host that
141
+ * stops idle machines (Fly) starts this one when there is work. A POST signed with another secret
142
+ * than `secret` (when given) is refused. Returns { server, wake } for runChecks.
143
+ */
144
+ export function listenForWork ({ port, secret = null, say = () => {} }) {
145
+ let waiting = []
146
+ const wake = () => new Promise((resolve) => { waiting.push(resolve) })
147
+ const ring = () => { const w = waiting; waiting = []; for (const r of w) r() }
148
+ const server = http.createServer((req, res) => {
149
+ if (req.method === 'GET') { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('seam runner\n'); return }
150
+ const chunks = []
151
+ req.on('data', (c) => { chunks.push(c); if (chunks.reduce((n, x) => n + x.length, 0) > 1 << 20) req.destroy() })
152
+ req.on('end', () => {
153
+ const body = Buffer.concat(chunks).toString('utf8')
154
+ if (secret && !verifyWebhook(secret, req.headers['x-quilt-timestamp'], body, req.headers['x-quilt-signature'])) { res.writeHead(401); res.end(); return }
155
+ res.writeHead(202); res.end()
156
+ say(`seam runner: woken by ${req.headers['x-quilt-event'] || 'a request'}`)
157
+ ring()
158
+ })
159
+ })
160
+ server.listen(port)
161
+ return { server, wake }
162
+ }