@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
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// 07's default conflict materialisation, `diff` (Jujutsu's style): inside each marker block one
|
|
2
|
+
// side as a snapshot (`+++++++`), then each other side as a diff from its base (`%%%%%%%`). With
|
|
3
|
+
// two sides (diff3), more sides on one base (an N-way merge, `nway`) or on different bases (each
|
|
4
|
+
// side's change laid onto the first side, `ontoFirst`) only the clashing regions are marked and the
|
|
5
|
+
// rest of the file is merged text; a deleted side is an empty side labelled "(deleted by <who>)". Also the `git` format (diff3 markers with the `|||||||` base section, two sides
|
|
6
|
+
// only). Every block's first line carries the term's tag, so a
|
|
7
|
+
// file committed with these markers is still conflicted (service.js, block.js).
|
|
8
|
+
import { diff3Merge } from 'node-diff3'
|
|
9
|
+
import { hunks, splitLines } from './textdiff.js'
|
|
10
|
+
|
|
11
|
+
/** `old` → `now` as `-`/`+`/` ` lines (every line of both, in order). */
|
|
12
|
+
export function lineDiff (old, now) {
|
|
13
|
+
const out = []
|
|
14
|
+
let i = 0
|
|
15
|
+
let j = 0
|
|
16
|
+
for (const h of hunks(old, now)) {
|
|
17
|
+
while (i < h.a[0]) { out.push(' ' + old[i++]); j++ }
|
|
18
|
+
for (let k = 0; k < h.a[1]; k++) out.push('-' + old[i++])
|
|
19
|
+
for (let k = 0; k < h.b[1]; k++) out.push('+' + now[j++])
|
|
20
|
+
}
|
|
21
|
+
while (i < old.length) { out.push(' ' + old[i++]); j++ }
|
|
22
|
+
return out
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The term as `diff` markers. `sides` are [{ text, label, by }] (adds, text null for a delete);
|
|
27
|
+
* `bases` the removes' texts (bases[i] is the base of sides[i + 1]). `tag` is
|
|
28
|
+
* `quilt-conflict <hash>`, `kind` the term kind.
|
|
29
|
+
*/
|
|
30
|
+
export function diffMarkers ({ sides, bases, tag, kind }) {
|
|
31
|
+
const L = (t) => splitLines(t || '')
|
|
32
|
+
// Ruling (2026-10-24): a side that deleted the file, or the region, is an empty side labelled so.
|
|
33
|
+
const name = (s, lines, base) => s.text == null || (!lines.length && base && base.length) ? `${s.label} (deleted by ${s.by || 'someone'})` : s.label
|
|
34
|
+
const block = (snap, others) => [
|
|
35
|
+
`<<<<<<< ${tag} ${kind}`,
|
|
36
|
+
`+++++++ ${name(snap.side, snap.lines, snap.base)}`, ...snap.lines,
|
|
37
|
+
...others.flatMap((o) => [`%%%%%%% ${name(o.side, o.lines, o.base)} (diff from base)`, ...lineDiff(o.base, o.lines)]),
|
|
38
|
+
`>>>>>>> ${tag}`
|
|
39
|
+
]
|
|
40
|
+
const whole = () => block({ side: sides[0], lines: L(sides[0].text), base: L(bases[0]) },
|
|
41
|
+
sides.slice(1).map((s, i) => ({ side: s, base: L(bases[i]), lines: L(s.text) })))
|
|
42
|
+
if (sides.length < 2) return whole().join('\n') + '\n'
|
|
43
|
+
const out = []
|
|
44
|
+
let marked = false
|
|
45
|
+
if (sides.length === 2) {
|
|
46
|
+
for (const r of diff3Merge(L(sides[0].text), L(bases[0]), L(sides[1].text), { excludeFalseConflicts: true })) {
|
|
47
|
+
if (r.ok) { out.push(...r.ok); continue }
|
|
48
|
+
const c = r.conflict
|
|
49
|
+
marked = true
|
|
50
|
+
out.push(...block({ side: sides[0], lines: c.a, base: c.o }, [{ side: sides[1], base: c.o, lines: c.b }]))
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
const regions = bases.some((b) => (b || '') !== (bases[0] || '')) ? ontoFirst(L(sides[0].text), sides.slice(1).map((s, i) => ({ base: L(bases[i]), lines: L(s.text) }))) : null
|
|
54
|
+
if (regions) {
|
|
55
|
+
for (const r of regions) {
|
|
56
|
+
if (r.ok) { out.push(...r.ok); continue }
|
|
57
|
+
marked = true
|
|
58
|
+
out.push(...block({ side: sides[0], lines: r.snap }, sides.slice(1).map((s, i) => ({ side: s, base: r.others[i].base, lines: r.others[i].lines }))))
|
|
59
|
+
}
|
|
60
|
+
} else for (const r of nway(L(bases[0]), sides.map((s) => L(s.text)))) {
|
|
61
|
+
if (r.ok) { out.push(...r.ok); continue }
|
|
62
|
+
marked = true
|
|
63
|
+
out.push(...block({ side: sides[0], lines: r.sides[0], base: r.base }, sides.slice(1).map((s, i) => ({ side: s, base: r.base, lines: r.sides[i + 1] }))))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return (marked ? out : whole()).join('\n') + '\n'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* An N-way line merge of `sides` (arrays of lines) that all start from `base`: regions
|
|
71
|
+
* `{ ok: lines }` where at most one distinct change touches the base lines, and
|
|
72
|
+
* `{ base, sides }` where two sides changed the same (or touching) base lines differently.
|
|
73
|
+
*/
|
|
74
|
+
export function nway (base, sides) {
|
|
75
|
+
const all = []
|
|
76
|
+
sides.forEach((lines, s) => { for (const h of hunks(base, lines)) all.push({ s, a0: h.a[0], a1: h.a[0] + h.a[1], d: h.b[1] - h.a[1] }) })
|
|
77
|
+
all.sort((x, y) => x.a0 - y.a0 || x.a1 - y.a1)
|
|
78
|
+
const groups = []
|
|
79
|
+
for (const h of all) {
|
|
80
|
+
const g = groups[groups.length - 1]
|
|
81
|
+
if (g && h.a0 <= g.hi) { g.hi = Math.max(g.hi, h.a1); g.hunks.push(h) } else groups.push({ lo: h.a0, hi: h.a1, hunks: [h] })
|
|
82
|
+
}
|
|
83
|
+
const shift = new Array(sides.length).fill(0) // each side's line offset before the current group
|
|
84
|
+
const out = []
|
|
85
|
+
let at = 0
|
|
86
|
+
const ok = (lines) => { if (lines.length) out.push({ ok: lines }) }
|
|
87
|
+
for (const g of groups) {
|
|
88
|
+
ok(base.slice(at, g.lo))
|
|
89
|
+
const changed = new Set(g.hunks.map((h) => h.s))
|
|
90
|
+
const slice = sides.map((lines, s) => {
|
|
91
|
+
const d = g.hunks.filter((h) => h.s === s).reduce((n, h) => n + h.d, 0)
|
|
92
|
+
const part = lines.slice(g.lo + shift[s], g.hi + shift[s] + d)
|
|
93
|
+
shift[s] += d
|
|
94
|
+
return part
|
|
95
|
+
})
|
|
96
|
+
const versions = new Set([...changed].map((s) => slice[s].join('\n') + '\u0000' + slice[s].length))
|
|
97
|
+
if (versions.size === 1) ok(slice[[...changed][0]])
|
|
98
|
+
else out.push({ base: base.slice(g.lo, g.hi), sides: slice })
|
|
99
|
+
at = g.hi
|
|
100
|
+
}
|
|
101
|
+
ok(base.slice(at))
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Maps line positions of `a` to `b` through their hunks; inside a changed run (or on an insertion), `lo` snaps to its start and `hi` to its end. */
|
|
106
|
+
function mapper (a, b) {
|
|
107
|
+
const hs = hunks(a, b)
|
|
108
|
+
const at = (p, end) => {
|
|
109
|
+
let off = 0
|
|
110
|
+
for (const h of hs) {
|
|
111
|
+
const [a0, an] = h.a
|
|
112
|
+
if (end && p === a0 && an === 0) return h.b[0] + h.b[1] // an end on an insertion takes it in
|
|
113
|
+
if (p <= a0) return p + off
|
|
114
|
+
if (p < a0 + an) return end ? h.b[0] + h.b[1] : h.b[0]
|
|
115
|
+
off += h.b[1] - an
|
|
116
|
+
}
|
|
117
|
+
return p + off
|
|
118
|
+
}
|
|
119
|
+
return { hunks: hs, lo: (p) => at(p, false), hi: (p) => at(p, true) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sides on different bases, each laid onto the first side (the snapshot): a side's change lands
|
|
124
|
+
* where its base lines sit in the first side. Regions `{ ok: lines }` where at most one change
|
|
125
|
+
* lands and it does not touch lines the first side changed from that base, and `{ snap, others }`
|
|
126
|
+
* (each other side's base and text over the region) where they clash.
|
|
127
|
+
*/
|
|
128
|
+
export function ontoFirst (first, others) {
|
|
129
|
+
const regions = []
|
|
130
|
+
others.forEach((o, i) => {
|
|
131
|
+
const toFirst = mapper(o.base, first)
|
|
132
|
+
const toSide = mapper(o.base, o.lines)
|
|
133
|
+
for (const h of toSide.hunks) {
|
|
134
|
+
let x0 = h.a[0]; let x1 = h.a[0] + h.a[1]; let clash = false
|
|
135
|
+
for (let grew = true; grew;) {
|
|
136
|
+
grew = false
|
|
137
|
+
for (const c of toFirst.hunks) {
|
|
138
|
+
const c0 = c.a[0]; const c1 = c.a[0] + c.a[1]
|
|
139
|
+
if (c0 > x1 || x0 > c1) continue
|
|
140
|
+
clash = true
|
|
141
|
+
if (c0 < x0 || c1 > x1) { x0 = Math.min(x0, c0); x1 = Math.max(x1, c1); grew = true }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
regions.push({ i, lo: toFirst.lo(x0), hi: toFirst.hi(x1), clash, text: o.lines.slice(toSide.lo(x0), toSide.hi(x1)) })
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
regions.sort((x, y) => x.lo - y.lo || x.hi - y.hi)
|
|
148
|
+
const groups = []
|
|
149
|
+
for (const r of regions) {
|
|
150
|
+
const g = groups[groups.length - 1]
|
|
151
|
+
if (g && r.lo <= g.hi) { g.hi = Math.max(g.hi, r.hi); g.rs.push(r) } else groups.push({ lo: r.lo, hi: r.hi, rs: [r] })
|
|
152
|
+
}
|
|
153
|
+
const out = []
|
|
154
|
+
let at = 0
|
|
155
|
+
const ok = (lines) => { if (lines.length) out.push({ ok: lines }) }
|
|
156
|
+
for (const g of groups) {
|
|
157
|
+
ok(first.slice(at, g.lo))
|
|
158
|
+
const same = g.rs.every((r) => !r.clash && r.lo === g.lo && r.hi === g.hi && r.text.join('\n') === g.rs[0].text.join('\n') && r.text.length === g.rs[0].text.length)
|
|
159
|
+
if (same) ok(g.rs[0].text)
|
|
160
|
+
else {
|
|
161
|
+
out.push({
|
|
162
|
+
snap: first.slice(g.lo, g.hi),
|
|
163
|
+
others: others.map((o) => {
|
|
164
|
+
const back = mapper(first, o.base)
|
|
165
|
+
const fwd = mapper(o.base, o.lines)
|
|
166
|
+
const [b0, b1] = [back.lo(g.lo), back.hi(g.hi)]
|
|
167
|
+
return { base: o.base.slice(b0, b1), lines: o.lines.slice(fwd.lo(b0), fwd.hi(b1)) }
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
at = g.hi
|
|
172
|
+
}
|
|
173
|
+
ok(first.slice(at))
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The term whole (07's `snapshot` format): every side, then every base, each as its full text. */
|
|
178
|
+
export function snapshotMarkers ({ sides, bases, tag, kind }) {
|
|
179
|
+
const parts = [`<<<<<<< ${tag} ${kind}`]
|
|
180
|
+
sides.forEach((s) => parts.push(`+++++++ ${s.label}`, s.text || ''))
|
|
181
|
+
bases.forEach((b, i) => parts.push(`------- base ${i}`, b || ''))
|
|
182
|
+
parts.push(`>>>>>>> ${tag}`)
|
|
183
|
+
return parts.join('\n') + '\n'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The term as git's diff3 markers (07's `git` format): two sides, with the `|||||||` base. */
|
|
187
|
+
export function gitMarkers ({ ours, base, theirs, tag }) {
|
|
188
|
+
const L = (t) => splitLines(t || '')
|
|
189
|
+
const name = (s, lines, o) => s.text == null || (!lines.length && o.length) ? `${s.label} (deleted by ${s.by || 'someone'})` : s.label
|
|
190
|
+
const block = (a, o, b) => [`<<<<<<< ${name(ours, a, o)} ${tag}`, ...a, `||||||| base ${tag}`, ...o, '=======', ...b, `>>>>>>> ${name(theirs, b, o)} ${tag}`]
|
|
191
|
+
const out = []
|
|
192
|
+
let marked = false
|
|
193
|
+
for (const r of diff3Merge(L(ours.text), L(base), L(theirs.text), { excludeFalseConflicts: true })) {
|
|
194
|
+
if (r.ok) { out.push(...r.ok); continue }
|
|
195
|
+
marked = true
|
|
196
|
+
out.push(...block(r.conflict.a, r.conflict.o, r.conflict.b))
|
|
197
|
+
}
|
|
198
|
+
return (marked ? out : block(L(ours.text), L(base), L(theirs.text))).join('\n') + '\n'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Reads a file with conflict markers back (any of the three formats): `[{ ok: lines }` for merged
|
|
203
|
+
* text, and `{ format, tag, kind, sides: [{ label, deleted, lines, base }] }` for each block]`.
|
|
204
|
+
* `base` is a side's base lines where the markers show it (a `diff` side after the first, a `git`
|
|
205
|
+
* block's two sides). A `snapshot` block also gives each side's exact `text` and the bases as
|
|
206
|
+
* `removes`, so it reads back to the term it was written from. Only blocks whose first line carries `quilt-conflict` are read; lines that merely look
|
|
207
|
+
* like markers outside them are text. A block left open or out of order throws `markers_malformed`.
|
|
208
|
+
*/
|
|
209
|
+
export function parseMarkers (text) {
|
|
210
|
+
const lines = splitLines(text || '')
|
|
211
|
+
const out = []
|
|
212
|
+
const bad = (why) => Object.assign(new Error(`conflict markers malformed: ${why}`), { code: 'markers_malformed' })
|
|
213
|
+
const label = (s) => { const m = /^(.*) \(deleted by [^)]*\)$/.exec(s); return m ? { label: m[1], deleted: true } : { label: s, deleted: false } }
|
|
214
|
+
let ok = []
|
|
215
|
+
const flush = () => { if (ok.length) out.push({ ok }); ok = [] }
|
|
216
|
+
for (let i = 0; i < lines.length; i++) {
|
|
217
|
+
const open = /^<<<<<<< (quilt-conflict \S+) (\S+)$/.exec(lines[i])
|
|
218
|
+
const gopen = !open && /^<<<<<<< (.*) (quilt-conflict \S+)$/.exec(lines[i])
|
|
219
|
+
if (!open && !gopen) { ok.push(lines[i]); continue }
|
|
220
|
+
flush()
|
|
221
|
+
const tag = open ? open[1] : gopen[2]
|
|
222
|
+
const close = open ? `>>>>>>> ${tag}` : null
|
|
223
|
+
const sides = []
|
|
224
|
+
const removes = []
|
|
225
|
+
let cur = null
|
|
226
|
+
let mode = null
|
|
227
|
+
for (i++; ; i++) {
|
|
228
|
+
if (i >= lines.length) throw bad(`block ${tag} is never closed`)
|
|
229
|
+
const l = lines[i]
|
|
230
|
+
if (open) {
|
|
231
|
+
if (l === close) break
|
|
232
|
+
if (l.startsWith('+++++++ ')) { cur = { ...label(l.slice(8)), lines: [] }; mode = 'snap'; sides.push(cur); continue }
|
|
233
|
+
if (/^------- base \d+$/.test(l) && mode !== 'diff' && sides.length) { cur = { base: true, lines: [] }; mode = 'snap'; removes.push(cur); continue }
|
|
234
|
+
if (l.startsWith('%%%%%%% ') && l.endsWith(' (diff from base)') && !removes.length) { cur = { ...label(l.slice(8, -17)), lines: [], base: [] }; mode = 'diff'; sides.push(cur); continue }
|
|
235
|
+
if (!cur) throw bad(`block ${tag} has text before its first side`)
|
|
236
|
+
if (mode === 'snap') { cur.lines.push(l); continue }
|
|
237
|
+
const op = l[0]
|
|
238
|
+
if (op === ' ') { cur.lines.push(l.slice(1)); cur.base.push(l.slice(1)) } else if (op === '+') cur.lines.push(l.slice(1))
|
|
239
|
+
else if (op === '-') cur.base.push(l.slice(1))
|
|
240
|
+
else throw bad(`block ${tag} has a diff line that is not -, + or space`)
|
|
241
|
+
} else {
|
|
242
|
+
if (mode === null) { cur = { ...label(gopen[1]), lines: [], base: [] }; sides.push(cur); mode = 'ours' }
|
|
243
|
+
if (mode === 'ours' && l === `||||||| base ${tag}`) { mode = 'base'; continue }
|
|
244
|
+
if (mode === 'base' && l === '=======') { mode = 'theirs'; sides.push(cur = { label: '', deleted: false, lines: [], base: sides[0].base }); continue }
|
|
245
|
+
if (mode === 'theirs' && l.startsWith('>>>>>>> ') && l.endsWith(' ' + tag)) { Object.assign(cur, label(l.slice(8, -tag.length - 1))); break }
|
|
246
|
+
if (mode === 'base') sides[0].base.push(l)
|
|
247
|
+
else cur.lines.push(l)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (open && !sides.length) throw bad(`block ${tag} has no sides`)
|
|
251
|
+
if (removes.length) {
|
|
252
|
+
// A snapshot block holds each text whole: the lines between two markers are the text plus
|
|
253
|
+
// the newline that joined it to the next marker, so joining them gives the exact bytes.
|
|
254
|
+
for (const s of sides) { s.text = s.lines.join('\n'); s.lines = splitLines(s.text) }
|
|
255
|
+
out.push({ format: 'snapshot', tag, kind: open[2], sides, removes: removes.map((r) => r.lines.join('\n')) })
|
|
256
|
+
} else out.push(open ? { format: 'diff', tag, kind: open[2], sides } : { format: 'git', tag, kind: null, sides })
|
|
257
|
+
}
|
|
258
|
+
flush()
|
|
259
|
+
return out
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The file with every block resolved to side `k` (what someone who picks that side keeps). */
|
|
263
|
+
export function resolveMarkers (regions, k) {
|
|
264
|
+
const lines = regions.flatMap((r) => r.ok || r.sides[k].lines)
|
|
265
|
+
return lines.length ? lines.join('\n') + '\n' : ''
|
|
266
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The daemon for one block folder (05 "Cache tiers", slice 1): it watches the folder, sends each
|
|
2
|
+
// changed path to the square's live work as a PATCH /live (the folder's head named, unchanged
|
|
3
|
+
// paths as `clean`), and answers a control API on 127.0.0.1. The API's port and token are in
|
|
4
|
+
// ~/.seam/daemon/<folder id>.json (0600), which only the folder's owner can read; every request
|
|
5
|
+
// carries `Authorization: Bearer <token>`. Routes: GET /status, POST /flush, POST /rebuild, POST /stop.
|
|
6
|
+
// On start it runs block.rebuild and shares every edit made while it was not running.
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import http from 'node:http'
|
|
11
|
+
import crypto from 'node:crypto'
|
|
12
|
+
import { findBlock, livePatch, rebuild, localChanges, touchFolder } from './block.js'
|
|
13
|
+
|
|
14
|
+
const seamHome = () => process.env.SEAM_HOME || path.join(os.homedir(), '.seam')
|
|
15
|
+
const SKIP = new Set(['.seam', '.git', 'node_modules'])
|
|
16
|
+
|
|
17
|
+
/** Where the daemon for a block folder keeps its port and token. */
|
|
18
|
+
export const daemonStatusFile = (dir) => path.join(seamHome(), 'daemon', crypto.createHash('sha256').update(path.resolve(dir)).digest('hex').slice(0, 16) + '.json')
|
|
19
|
+
|
|
20
|
+
/** At most this many daemons on one machine (05 "Several blocks": each costs a native watcher). */
|
|
21
|
+
export const MAX_DAEMONS = 200
|
|
22
|
+
|
|
23
|
+
/** The daemons running on this machine: their status files whose process is alive. */
|
|
24
|
+
export function runningDaemons () {
|
|
25
|
+
const d = path.join(seamHome(), 'daemon')
|
|
26
|
+
const out = []
|
|
27
|
+
for (const n of fs.existsSync(d) ? fs.readdirSync(d) : []) {
|
|
28
|
+
if (!n.endsWith('.json')) continue
|
|
29
|
+
try {
|
|
30
|
+
const info = JSON.parse(fs.readFileSync(path.join(d, n), 'utf8'))
|
|
31
|
+
process.kill(info.pid, 0)
|
|
32
|
+
out.push(info)
|
|
33
|
+
} catch (err) { if (err.code === 'EPERM') out.push({ file: n }) }
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Asks the daemon running for a folder (null when none answers). */
|
|
39
|
+
export async function askDaemon (dir, route = 'status', method = 'GET') {
|
|
40
|
+
let info
|
|
41
|
+
try { info = JSON.parse(fs.readFileSync(daemonStatusFile(dir), 'utf8')) } catch { return null }
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/${route}`, { method, headers: { authorization: `Bearer ${info.token}` }, signal: AbortSignal.timeout(3000) })
|
|
44
|
+
return res.ok ? await res.json() : null
|
|
45
|
+
} catch { return null }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Starts the daemon for the block folder `dir`: { port, status(), flush(), stop() }. A path is sent
|
|
50
|
+
* `debounceMs` after its last change. A `409 head_moved` keeps the paths pending (state
|
|
51
|
+
* `head_moved`) until the folder's head is the square's again (seam rebase or seam checkout);
|
|
52
|
+
* any other failure keeps them for the next try.
|
|
53
|
+
*/
|
|
54
|
+
export async function startDaemon ({ client, dir, debounceMs = 1000, codec = null, touchMs = 10 * 60 * 1000 }) {
|
|
55
|
+
const found = findBlock(dir)
|
|
56
|
+
if (!found) throw new Error(`${dir} is not a block folder: run seam checkout first.`)
|
|
57
|
+
dir = found.dir
|
|
58
|
+
if (!found.block.square) throw new Error('This block is not on a square, so it has no live work to share: open one with seam square open.')
|
|
59
|
+
if (found.block.read_only) throw new Error('This block is read-only, so it has no live work to share.')
|
|
60
|
+
const others = runningDaemons().filter((i) => i.dir !== dir)
|
|
61
|
+
const max = Number(process.env.SEAM_MAX_DAEMONS) || MAX_DAEMONS
|
|
62
|
+
if (others.length >= max) throw Object.assign(new Error(`${others.length} seam daemons already run on this machine (at most ${max}): stop one with seam daemon stop in its folder.`), { code: 'daemons_limit' })
|
|
63
|
+
const pending = new Set()
|
|
64
|
+
const st = { dir, quilt: found.block.quilt, square: found.block.square, head: found.block.head || null, state: 'watching', sent: 0, last_sent_at: null, last_error: null, pid: process.pid, started_at: new Date().toISOString() }
|
|
65
|
+
let timer = null
|
|
66
|
+
let running = null
|
|
67
|
+
let stopped = false
|
|
68
|
+
let retryAt = 0
|
|
69
|
+
let inflight = []
|
|
70
|
+
let lastRebuild = null
|
|
71
|
+
|
|
72
|
+
// block.rebuild (05): finish a move that stopped part way, then queue every edit made while no
|
|
73
|
+
// daemon watched, so it is shared like one made now.
|
|
74
|
+
const rebuildNow = async () => {
|
|
75
|
+
const { block } = findBlock(dir)
|
|
76
|
+
if (codec) block._codec = codec
|
|
77
|
+
lastRebuild = await rebuild(client, dir, block)
|
|
78
|
+
const fresh = findBlock(dir).block
|
|
79
|
+
for (const c of localChanges(dir, fresh)) pending.add(c.path)
|
|
80
|
+
for (const p of lastRebuild.kept) pending.add(p)
|
|
81
|
+
return lastRebuild
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const flush = async () => {
|
|
85
|
+
if (running) { await running; if (!pending.size) return }
|
|
86
|
+
if (!pending.size) return
|
|
87
|
+
const paths = [...pending].sort()
|
|
88
|
+
pending.clear()
|
|
89
|
+
inflight = paths
|
|
90
|
+
running = (async () => {
|
|
91
|
+
const { block } = findBlock(dir)
|
|
92
|
+
st.head = block.head || null
|
|
93
|
+
try {
|
|
94
|
+
const patch = livePatch(dir, block, paths.map((p) => path.join(dir, p)))
|
|
95
|
+
if (patch.changes.length) await client.patchLive(block.quilt, block.square, patch)
|
|
96
|
+
st.sent += 1
|
|
97
|
+
st.last_sent_at = new Date().toISOString()
|
|
98
|
+
st.state = 'watching'
|
|
99
|
+
st.last_error = null
|
|
100
|
+
} catch (err) {
|
|
101
|
+
for (const p of paths) pending.add(p)
|
|
102
|
+
st.state = err.code === 'head_moved' ? 'head_moved' : 'retrying'
|
|
103
|
+
retryAt = Date.now() + 10000
|
|
104
|
+
st.last_error = err.code === 'head_moved' ? `the square's head is ${String(err.head || '').slice(0, 12)}, not this folder's: run seam rebase` : err.message
|
|
105
|
+
}
|
|
106
|
+
})()
|
|
107
|
+
try { await running } finally { running = null; inflight = [] }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The block row's last_seen is touched every 10 minutes while the daemon runs (05); a row the
|
|
111
|
+
// server closed while the machine was away is opened again on the same square and paths.
|
|
112
|
+
const touch = async () => {
|
|
113
|
+
try { const r = await touchFolder(client, dir, findBlock(dir).block); st.block_id = r.block_id; st.last_touch_at = new Date().toISOString(); if (r.reopened) st.reopened_at = st.last_touch_at } catch (err) { st.last_error = err.message }
|
|
114
|
+
}
|
|
115
|
+
await rebuildNow()
|
|
116
|
+
await touch()
|
|
117
|
+
const schedule = () => { if (stopped) return; clearTimeout(timer); timer = setTimeout(() => { flush().catch(() => {}) }, debounceMs) }
|
|
118
|
+
const watcher = fs.watch(dir, { recursive: true }, (_ev, name) => {
|
|
119
|
+
if (!name) return
|
|
120
|
+
const rel = String(name).split(path.sep).join('/')
|
|
121
|
+
if (SKIP.has(rel.split('/')[0])) return
|
|
122
|
+
pending.add(rel)
|
|
123
|
+
schedule()
|
|
124
|
+
})
|
|
125
|
+
if (pending.size) schedule()
|
|
126
|
+
// A failed send tries again every 10 seconds; a head_moved one once the folder's head changes.
|
|
127
|
+
const retry = setInterval(() => {
|
|
128
|
+
if (!pending.size || running) return
|
|
129
|
+
if (st.state === 'head_moved' && (findBlock(dir)?.block.head || null) === st.head) return
|
|
130
|
+
if (st.state === 'retrying' && Date.now() < retryAt) return
|
|
131
|
+
flush().catch(() => {})
|
|
132
|
+
}, Math.max(debounceMs, 200))
|
|
133
|
+
retry.unref?.()
|
|
134
|
+
const toucher = setInterval(() => { touch() }, touchMs)
|
|
135
|
+
toucher.unref?.()
|
|
136
|
+
|
|
137
|
+
const token = crypto.randomBytes(24).toString('base64url')
|
|
138
|
+
// Waiting = not sent yet, including paths in a send that has not answered.
|
|
139
|
+
const status = () => ({ ...st, pending: [...new Set([...pending, ...inflight])].sort(), last_rebuild: lastRebuild })
|
|
140
|
+
const server = http.createServer(async (req, res) => {
|
|
141
|
+
const send = (code, body) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)) }
|
|
142
|
+
const given = Buffer.from(String(req.headers.authorization || ''))
|
|
143
|
+
const want = Buffer.from(`Bearer ${token}`)
|
|
144
|
+
if (given.length !== want.length || !crypto.timingSafeEqual(given, want)) return send(401, { error: 'This daemon needs its token (in ~/.seam/daemon).', code: 'unauthorized' })
|
|
145
|
+
const route = `${req.method} ${req.url.split('?')[0]}`
|
|
146
|
+
if (route === 'GET /status') return send(200, status())
|
|
147
|
+
if (route === 'POST /flush') { await flush(); return send(200, status()) }
|
|
148
|
+
if (route === 'POST /rebuild') { try { const r = await rebuildNow(); schedule(); return send(200, { ...r, status: status() }) } catch (err) { return send(500, { error: err.message, code: err.code || 'rebuild_failed' }) } }
|
|
149
|
+
if (route === 'POST /stop') { send(200, { stopping: true }); setImmediate(() => stop()); return }
|
|
150
|
+
send(404, { error: `No ${route} here: GET /status, POST /flush, POST /rebuild, POST /stop.`, code: 'not_found' })
|
|
151
|
+
})
|
|
152
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
|
|
153
|
+
const port = server.address().port
|
|
154
|
+
const file = daemonStatusFile(dir)
|
|
155
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
156
|
+
fs.writeFileSync(file, JSON.stringify({ pid: process.pid, port, token, dir }), { mode: 0o600 })
|
|
157
|
+
fs.chmodSync(file, 0o600)
|
|
158
|
+
|
|
159
|
+
let done = null
|
|
160
|
+
let ended
|
|
161
|
+
const endedP = new Promise((resolve) => { ended = resolve })
|
|
162
|
+
const stop = () => done || (done = (async () => {
|
|
163
|
+
stopped = true
|
|
164
|
+
clearTimeout(timer)
|
|
165
|
+
clearInterval(retry)
|
|
166
|
+
clearInterval(toucher)
|
|
167
|
+
watcher.close()
|
|
168
|
+
if (running) await running.catch(() => {})
|
|
169
|
+
try { if (JSON.parse(fs.readFileSync(file, 'utf8')).token === token) fs.rmSync(file) } catch {}
|
|
170
|
+
await new Promise((resolve) => { server.close(() => resolve()); server.closeAllConnections?.() })
|
|
171
|
+
ended()
|
|
172
|
+
})())
|
|
173
|
+
return { port, status, flush, stop, stopped: () => endedP }
|
|
174
|
+
}
|