@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/pack.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
75
|
+
return { flags, data }
|
|
30
76
|
}
|
|
31
77
|
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
36
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
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)
|
|
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
|
+
}
|
package/lib/quilts/records.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/lib/quilts/runner.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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$ ${
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
+
}
|