@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.
- 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 +623 -69
- package/lib/quilts/client.js +307 -19
- package/lib/quilts/conflict-format.js +266 -0
- package/lib/quilts/daemon.js +174 -0
- package/lib/quilts/describe.js +281 -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 +412 -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,78 @@
|
|
|
1
|
+
// The machine cache (plans/quilt-source-control/04 "Three tiers", 05): immutable objects by id,
|
|
2
|
+
// shared by every block on this machine and every quilt, in ~/.seam/cas/<kind>/<id[0:2]>/<id>.
|
|
3
|
+
// It plans downloads and nothing else (04): what is here is never a reason to skip an upload.
|
|
4
|
+
// Every read checks the bytes against the id, so a file changed on disk is dropped, never served.
|
|
5
|
+
// Kept under a byte cap (SEAM_CAS_BYTES, default 2 GiB; 0 turns it off), least recently used
|
|
6
|
+
// first out. Only plain objects whose id is the hash of their bytes go in: tree nodes and the
|
|
7
|
+
// files of quilts that are not sealed.
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { idOf, isId } from './hash.js'
|
|
12
|
+
|
|
13
|
+
export const CAS_BYTES = 2 * 1024 ** 3
|
|
14
|
+
|
|
15
|
+
export function machineCas ({ dir = path.join(os.homedir(), '.seam', 'cas'), env = process.env, capBytes } = {}) {
|
|
16
|
+
const cap = capBytes ?? (env.SEAM_CAS_BYTES !== undefined && env.SEAM_CAS_BYTES !== '' ? Number(env.SEAM_CAS_BYTES) : CAS_BYTES)
|
|
17
|
+
const on = Number.isFinite(cap) && cap > 0
|
|
18
|
+
let total = null // bytes held, counted once per process on first write
|
|
19
|
+
const file = (kind, id) => path.join(dir, kind, id.slice(0, 2), id)
|
|
20
|
+
const all = () => {
|
|
21
|
+
const out = []
|
|
22
|
+
for (const kind of fs.existsSync(dir) ? fs.readdirSync(dir) : []) {
|
|
23
|
+
const kd = path.join(dir, kind)
|
|
24
|
+
for (const pre of fs.statSync(kd).isDirectory() ? fs.readdirSync(kd) : []) {
|
|
25
|
+
for (const id of fs.readdirSync(path.join(kd, pre))) {
|
|
26
|
+
const f = path.join(kd, pre, id)
|
|
27
|
+
try { const st = fs.statSync(f); out.push({ f, size: st.size, used: st.mtimeMs }) } catch {}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
const count = () => { if (total === null) total = all().reduce((n, x) => n + x.size, 0); return total }
|
|
34
|
+
const prune = () => {
|
|
35
|
+
const list = all().sort((a, b) => a.used - b.used)
|
|
36
|
+
total = list.reduce((n, x) => n + x.size, 0)
|
|
37
|
+
for (const x of list) {
|
|
38
|
+
if (total <= cap) break
|
|
39
|
+
try { fs.rmSync(x.f, { force: true }); total -= x.size } catch {}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
file,
|
|
44
|
+
bytes: () => count(),
|
|
45
|
+
/** The object's bytes, or null (not here, off, or changed on disk: then removed). */
|
|
46
|
+
get (kind, id) {
|
|
47
|
+
if (!on || !isId(id)) return null
|
|
48
|
+
const f = file(kind, id)
|
|
49
|
+
let b
|
|
50
|
+
try { b = fs.readFileSync(f) } catch { return null }
|
|
51
|
+
if (idOf(kind, b) !== id) { fs.rmSync(f, { force: true }); if (total !== null) total -= b.length; return null }
|
|
52
|
+
const t = new Date()
|
|
53
|
+
try { fs.utimesSync(f, t, t) } catch {}
|
|
54
|
+
return b
|
|
55
|
+
},
|
|
56
|
+
/** Keeps an object whose bytes hash to its id; false when they do not, or the cache is off. */
|
|
57
|
+
put (kind, id, bytes) {
|
|
58
|
+
if (!on || !isId(id) || bytes.length > cap || idOf(kind, bytes) !== id) return false
|
|
59
|
+
const f = file(kind, id)
|
|
60
|
+
if (fs.existsSync(f)) return true
|
|
61
|
+
// A home folder this process cannot write (a locked-down CI job) leaves the cache empty, never fails a checkout.
|
|
62
|
+
try {
|
|
63
|
+
count()
|
|
64
|
+
fs.mkdirSync(path.dirname(f), { recursive: true })
|
|
65
|
+
const tmp = `${f}.${process.pid}.tmp`
|
|
66
|
+
fs.writeFileSync(tmp, bytes)
|
|
67
|
+
fs.renameSync(tmp, f)
|
|
68
|
+
total += bytes.length
|
|
69
|
+
if (total > cap) prune()
|
|
70
|
+
return true
|
|
71
|
+
} catch { return false }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let shared = null
|
|
77
|
+
/** This process's machine cache (home and settings read once). */
|
|
78
|
+
export const theCas = () => (shared ??= machineCas())
|
package/lib/quilts/cbor.js
CHANGED
|
@@ -56,8 +56,10 @@ export function encode (v) {
|
|
|
56
56
|
export function decode (buf) {
|
|
57
57
|
const b = Buffer.from(buf.buffer ? buf : Buffer.from(buf))
|
|
58
58
|
let pos = 0
|
|
59
|
+
const need = (n) => { if (pos + n > b.length) throw new Error('cbor: truncated') }
|
|
59
60
|
const arg = (info) => {
|
|
60
61
|
if (info < 24) return info
|
|
62
|
+
if (info <= 27) need(1 << (info - 24))
|
|
61
63
|
if (info === 24) return b[pos++]
|
|
62
64
|
if (info === 25) { const n = b.readUInt16BE(pos); pos += 2; return n }
|
|
63
65
|
if (info === 26) { const n = b.readUInt32BE(pos); pos += 4; return n }
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Claims on a square's files (plans/quilt-source-control/06, "Claims on squares"): a claim says
|
|
2
|
+
// "this square is working on these paths" so nobody else spends work on them. A write under a
|
|
3
|
+
// claimed pattern from another open square on the same line is refused (412 claim_refused) and the
|
|
4
|
+
// writer is told who holds it and why. A claim lasts while its square is open: merging, abandoning
|
|
5
|
+
// or archiving the square lets go of it. Merges never check claims; overlapping work that was
|
|
6
|
+
// written before a claim existed still meets at the merge (07).
|
|
7
|
+
import { QuiltError, squareShort } from './errors.js'
|
|
8
|
+
import { uuidv7 } from './ids.js'
|
|
9
|
+
|
|
10
|
+
export const MAX_PATTERN = 500
|
|
11
|
+
export const MAX_NOTE = 300
|
|
12
|
+
export const MAX_CLAIMS = 200 // per square
|
|
13
|
+
|
|
14
|
+
/** A path, folder or simple glob (*, **, ?) as a matcher over file paths. */
|
|
15
|
+
export function globMatcher (pattern) {
|
|
16
|
+
const p = String(pattern).trim().replace(/^\.\//, '')
|
|
17
|
+
if (!/[*?]/.test(p)) {
|
|
18
|
+
const base = p.replace(/\/+$/, '')
|
|
19
|
+
return (rel) => rel === base || rel.startsWith(base + '/')
|
|
20
|
+
}
|
|
21
|
+
let re = ''
|
|
22
|
+
for (let i = 0; i < p.length; i++) {
|
|
23
|
+
const c = p[i]
|
|
24
|
+
if (c === '*' && p[i + 1] === '*') {
|
|
25
|
+
re += '.*'
|
|
26
|
+
i++
|
|
27
|
+
if (p[i + 1] === '/') i++
|
|
28
|
+
} else if (c === '*') re += '[^/]*'
|
|
29
|
+
else if (c === '?') re += '[^/]'
|
|
30
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
31
|
+
}
|
|
32
|
+
const rx = new RegExp(`^${re}$`)
|
|
33
|
+
return (rel) => rx.test(rel)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether two patterns can cover the same file (paths: files that exist now). */
|
|
37
|
+
export function patternsOverlap (a, b, paths = []) {
|
|
38
|
+
if (a === b) return true
|
|
39
|
+
const ma = globMatcher(a)
|
|
40
|
+
const mb = globMatcher(b)
|
|
41
|
+
if (ma(b) || mb(a) || paths.some((p) => ma(p) && mb(p))) return true
|
|
42
|
+
// A glob reaches every file under the text before its first wildcard: when one pattern's stem
|
|
43
|
+
// is inside the other's, they may meet on a file that does not exist yet.
|
|
44
|
+
const stem = (p) => p.split(/[*?]/)[0]
|
|
45
|
+
const [sa, sb] = [stem(a), stem(b)]
|
|
46
|
+
if (sa === a && sb === b) return false
|
|
47
|
+
const [short, long] = sa.length <= sb.length ? [sa, sb] : [sb, sa]
|
|
48
|
+
return short !== '' && (long.startsWith(short.endsWith('/') ? short : short + '/') || long === short)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A claim pattern: a relative path, folder or glob, no `..`. */
|
|
52
|
+
export function claimPattern (raw) {
|
|
53
|
+
const p = String(raw || '').trim().replace(/^\.\//, '')
|
|
54
|
+
if (!p || p.length > MAX_PATTERN || p.startsWith('/') || p.split('/').includes('..') || p.includes('\0') || p.includes('\\')) {
|
|
55
|
+
throw new QuiltError(400, 'bad_request', `a claim is a relative path, folder or glob (*, **, ?), up to ${MAX_PATTERN} characters`)
|
|
56
|
+
}
|
|
57
|
+
return p
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ACTIVE = new Set(['open', 'merging'])
|
|
61
|
+
|
|
62
|
+
/** 06 "Auto-claims following edits": an auto-claim lets go this long after the last write to it. */
|
|
63
|
+
export const AUTO_CLAIM_QUIET_MS = 10 * 60_000
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* After square `sq` wrote `paths` (SEAM_AUTO_CLAIMS): claims each path nobody's live claim covers
|
|
67
|
+
* for it, `auto`, and refreshes its own auto-claims on paths it wrote again. A path under a claim
|
|
68
|
+
* (its own made on purpose, or a parent's it writes under) is left alone. Best effort: a write never
|
|
69
|
+
* fails for it.
|
|
70
|
+
*/
|
|
71
|
+
export async function autoClaim (store, q, sq, paths, holder, at, idleMs = 0) {
|
|
72
|
+
if (!paths.length || sq.state !== 'open') return
|
|
73
|
+
try {
|
|
74
|
+
const live = (await liveClaims(store, q, at, idleMs)).filter((c) => c.line === sq.line)
|
|
75
|
+
let mine = live.filter((c) => c.square_id === sq.id).length
|
|
76
|
+
for (const p of new Set(paths)) {
|
|
77
|
+
if (/[*?]/.test(p) || p.length > MAX_PATTERN) continue
|
|
78
|
+
const covering = live.filter((c) => globMatcher(c.pattern)(p))
|
|
79
|
+
const own = covering.find((c) => c.auto && c.square_id === sq.id && c.pattern === p)
|
|
80
|
+
if (own) {
|
|
81
|
+
await store.deleteClaim({ quilt_id: q, id: own.id })
|
|
82
|
+
await store.createClaim({ quilt_id: q, id: uuidv7(at), square_id: sq.id, pattern: p, holder: own.holder, note: own.note || '', at, auto: true })
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (covering.length || mine >= MAX_CLAIMS) continue
|
|
86
|
+
await store.createClaim({ quilt_id: q, id: uuidv7(at), square_id: sq.id, pattern: p, holder, note: '', at, auto: true })
|
|
87
|
+
mine++
|
|
88
|
+
}
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 06 "Owner mode": the default owner paths, the costliest files to conflict in. */
|
|
93
|
+
export const OWNER_PATHS_DEFAULT = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'Cargo.lock', 'go.sum', 'supabase/migrations/**']
|
|
94
|
+
/** An owner claim lets go this long after the last write under it from its square (owner_idle_hours' default). */
|
|
95
|
+
export const OWNER_IDLE_MS = 2 * 3600_000
|
|
96
|
+
/** A quilt's owner idle time: settings.owner_idle_hours (1..24) or the 2-hour default. */
|
|
97
|
+
export const ownerIdleMsOf = (quilt) => Number.isInteger(quilt?.settings?.owner_idle_hours) ? quilt.settings.owner_idle_hours * 3600_000 : OWNER_IDLE_MS
|
|
98
|
+
|
|
99
|
+
/** SEAM_OWNER_PATHS: unset is off, `default` the list above, else comma-separated patterns. */
|
|
100
|
+
export function ownerPathsFrom (v) {
|
|
101
|
+
const s = String(v ?? '').trim()
|
|
102
|
+
if (!s) return []
|
|
103
|
+
if (s === 'default') return [...OWNER_PATHS_DEFAULT]
|
|
104
|
+
return s.split(',').map((x) => x.trim()).filter(Boolean).map(claimPattern)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* After square `sq` wrote `paths` (SEAM_OWNER_PATHS): a write under an owner pattern claims the
|
|
109
|
+
* whole pattern for the square, `owner`, or refreshes the square's own owner claim on it. A pattern
|
|
110
|
+
* another square holds was already refused at the write. Best effort, like autoClaim.
|
|
111
|
+
*/
|
|
112
|
+
export async function ownerClaim (store, q, sq, paths, holder, at, idleMs = 0, ownerPaths = []) {
|
|
113
|
+
if (!paths.length || !ownerPaths.length || sq.state !== 'open') return
|
|
114
|
+
try {
|
|
115
|
+
const hit = ownerPaths.filter((o) => paths.some((p) => globMatcher(o)(p)))
|
|
116
|
+
if (!hit.length) return
|
|
117
|
+
const live = (await liveClaims(store, q, at, idleMs)).filter((c) => c.line === sq.line && c.square_id === sq.id)
|
|
118
|
+
for (const o of hit) {
|
|
119
|
+
const own = live.find((c) => c.pattern === o)
|
|
120
|
+
if (own && !own.owner) continue // claimed on purpose: it holds until the square ends
|
|
121
|
+
if (own) await store.deleteClaim({ quilt_id: q, id: own.id })
|
|
122
|
+
else if (live.length >= MAX_CLAIMS) continue
|
|
123
|
+
await store.createClaim({ quilt_id: q, id: uuidv7(at), square_id: sq.id, pattern: o, holder: own?.holder || holder, note: own?.note || '', at, owner: true })
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Claims after a write: owner patterns first (they cover their paths), then auto-claims. */
|
|
129
|
+
export async function claimWrites (store, q, sq, paths, holder, at, { idleMs = 0, auto = false, ownerPaths = [], unchanged = [] } = {}) {
|
|
130
|
+
// 06: a write that leaves an owner path as the square had it (an install that changed nothing) takes no owner claim.
|
|
131
|
+
if (ownerPaths.length) await ownerClaim(store, q, sq, paths.filter((p) => !unchanged.includes(p)), holder, at, idleMs, ownerPaths)
|
|
132
|
+
if (auto) await autoClaim(store, q, sq, paths, holder, at, idleMs)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Moves claim `c` to request `r`'s square and holder, re-points the rest of its queue at the new
|
|
137
|
+
* square in order, and writes the `claim_handoff` row the events job turns into claim.granted and
|
|
138
|
+
* claim.handoff. Used by a handoff and by the idle sweep (06 "Queues and idle release").
|
|
139
|
+
*/
|
|
140
|
+
export async function moveClaim (store, qid, c, r, note, actor, at, why = null) {
|
|
141
|
+
await store.deleteClaim({ quilt_id: qid, id: c.id })
|
|
142
|
+
const row = await store.createClaim({ quilt_id: qid, id: uuidv7(at), square_id: r.by_square, pattern: c.pattern, holder: r.by, note: note || c.note || '', at, ...(c.expires_at ? { expires_at: c.expires_at } : {}) })
|
|
143
|
+
await store.deleteClaimRequest({ quilt_id: qid, id: r.id })
|
|
144
|
+
for (const x of queueOf(await store.claimRequests(qid), c)) {
|
|
145
|
+
await store.deleteClaimRequest({ quilt_id: qid, id: x.id })
|
|
146
|
+
if (x.by_square !== r.by_square) await store.addClaimRequest({ ...x, square_id: r.by_square })
|
|
147
|
+
}
|
|
148
|
+
await store.setSquare({ square_id: r.by_square, op: 'claim_handoff', patch: {}, log: { pattern: c.pattern, from: c.holder, from_square: c.square_id, to: r.by, note: note || c.note || '', ...(why ? { why } : {}) }, actor, at }).catch(() => {})
|
|
149
|
+
return row
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 06 owner mode: an owner claim lets go once its square no longer changes anything under the
|
|
154
|
+
* pattern (`changed`: the paths that differ between the square's base and its new head, after a
|
|
155
|
+
* revert or a restore). The first waiter gets it (claim.granted, why settled); with nobody waiting
|
|
156
|
+
* it is released.
|
|
157
|
+
*/
|
|
158
|
+
export async function releaseSettled (store, q, sq, changed, at) {
|
|
159
|
+
const mine = (await store.claims(q)).filter((c) => c.owner && c.square_id === sq.id)
|
|
160
|
+
for (const c of mine) {
|
|
161
|
+
const m = globMatcher(c.pattern)
|
|
162
|
+
if (changed.some((p) => m(p))) continue
|
|
163
|
+
const waiting = queueOf(await store.claimRequests(q), c)
|
|
164
|
+
if (waiting.length) await moveClaim(store, q, c, waiting[0], '', 'system', at, 'settled')
|
|
165
|
+
else await store.deleteClaim({ quilt_id: q, id: c.id })
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** When square `sq` (open) last moved for claim `c`: its last change, its live work, or the claim itself. */
|
|
170
|
+
export async function claimActiveAt (store, qid, sq, c) {
|
|
171
|
+
return Math.max(sq.updated_at || 0, sq.opened_at || 0, (await store.getLive(qid, sq.id))?.at || 0, c.at || 0)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The claims that still hold at `at`: those of open squares not past their `expires_at`. With
|
|
176
|
+
* `idleMs` (06 "Queues and idle release", SEAM_CLAIM_IDLE_MINUTES) a claim whose square has seen
|
|
177
|
+
* nothing for that long (no commit or other change to the square, no live work, the claim itself
|
|
178
|
+
* not newer) has let go.
|
|
179
|
+
*/
|
|
180
|
+
export async function liveClaims (store, q, at = Date.now(), idleMs = 0) {
|
|
181
|
+
const rows = await store.claims(q)
|
|
182
|
+
if (!rows.length) return []
|
|
183
|
+
const squares = new Map()
|
|
184
|
+
const active = new Map()
|
|
185
|
+
const out = []
|
|
186
|
+
let ownerIdleMs = null
|
|
187
|
+
for (const c of rows) {
|
|
188
|
+
if (c.owner && ownerIdleMs === null) ownerIdleMs = ownerIdleMsOf(await store.getQuilt(q))
|
|
189
|
+
if (!squares.has(c.square_id)) squares.set(c.square_id, await store.getSquare(c.square_id))
|
|
190
|
+
const sq = squares.get(c.square_id)
|
|
191
|
+
if (!sq || !ACTIVE.has(sq.state) || c.expires_at <= at) continue
|
|
192
|
+
if (c.auto && (c.at || 0) + AUTO_CLAIM_QUIET_MS <= at) continue
|
|
193
|
+
if (c.owner && (c.at || 0) + ownerIdleMs <= at) continue
|
|
194
|
+
if (idleMs > 0 && sq.state === 'open') {
|
|
195
|
+
if (!active.has(sq.id)) active.set(sq.id, Math.max(sq.updated_at || 0, sq.opened_at || 0, (await store.getLive(q, sq.id))?.at || 0))
|
|
196
|
+
if (Math.max(active.get(sq.id), c.at || 0) + idleMs <= at) continue
|
|
197
|
+
}
|
|
198
|
+
out.push({ ...c, line: sq.line })
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Refuses a write to `paths` from square `sq` when another open square on its line claims one.
|
|
205
|
+
* Given the `writer` (a commit, not live work, which repeats), the refusal is also written to the
|
|
206
|
+
* square's log so the writer hears claim.refused (12) wherever it reads events.
|
|
207
|
+
*/
|
|
208
|
+
export async function guardClaims (store, q, sq, paths, at = Date.now(), idleMs = 0, writer = null) {
|
|
209
|
+
if (!paths.length) return
|
|
210
|
+
// A stacked square writes under its parents' claims (06): it builds on their work.
|
|
211
|
+
const mine = new Set([sq.id])
|
|
212
|
+
for (let p = sq; p.parent_square && mine.size <= 10;) { mine.add(p.parent_square); p = await store.getSquare(p.parent_square); if (!p) break }
|
|
213
|
+
const others = (await liveClaims(store, q, at, idleMs)).filter((c) => !mine.has(c.square_id) && c.line === sq.line)
|
|
214
|
+
for (const c of others) {
|
|
215
|
+
const m = globMatcher(c.pattern)
|
|
216
|
+
const hit = paths.find((p) => m(p))
|
|
217
|
+
if (hit) {
|
|
218
|
+
if (writer) {
|
|
219
|
+
const pos = queueOf(await store.claimRequests(q), c).findIndex((r) => r.by_square === sq.id)
|
|
220
|
+
await store.setSquare({ square_id: sq.id, op: 'claim_refused', patch: {}, log: { path: hit, claim: c.id, pattern: c.pattern, holder: c.holder, holder_square: c.square_id, note: c.note || '', queue_position: pos < 0 ? null : pos + 1 }, actor: writer, at }).catch(() => {})
|
|
221
|
+
}
|
|
222
|
+
throw new QuiltError(412, 'claim_refused', `${hit} is claimed by ${c.holder} in ${squareShort(c.square_id)}${c.note ? ` (${c.note})` : ''}: wait for it, ask them to hand it over, or work on other files`,
|
|
223
|
+
{ path: hit, claim: view(c) })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 06's `claims_overlap` for square `sq`: the claims that hold on its line, held by other squares
|
|
230
|
+
* (not its own stack's parents, whose claims it writes under), that cover a path it changed
|
|
231
|
+
* (`changed`) or overlap a pattern it claims. Each with holder, task and the paths it covers.
|
|
232
|
+
*/
|
|
233
|
+
export async function claimsOverlap (store, q, sq, changed = [], at = Date.now(), idleMs = 0) {
|
|
234
|
+
if (!['open', 'merging'].includes(sq.state)) return []
|
|
235
|
+
const mine = new Set([sq.id])
|
|
236
|
+
for (let p = sq; p.parent_square && mine.size <= 10;) { mine.add(p.parent_square); p = await store.getSquare(p.parent_square); if (!p) break }
|
|
237
|
+
const live = await liveClaims(store, q, at, idleMs)
|
|
238
|
+
const held = live.filter((c) => c.square_id === sq.id).map((c) => c.pattern)
|
|
239
|
+
const out = []
|
|
240
|
+
for (const c of live) {
|
|
241
|
+
if (mine.has(c.square_id) || c.line !== sq.line) continue
|
|
242
|
+
const m = globMatcher(c.pattern)
|
|
243
|
+
const paths = changed.filter((p) => m(p))
|
|
244
|
+
const patterns = held.filter((h) => patternsOverlap(h, c.pattern, changed))
|
|
245
|
+
if (!paths.length && !patterns.length) continue
|
|
246
|
+
const holderSq = await store.getSquare(c.square_id)
|
|
247
|
+
out.push({ ...view(c), ...(holderSq?.task_id ? { task_id: holderSq.task_id } : {}), paths: paths.slice(0, 50), ...(patterns.length ? { your_claims: patterns } : {}) })
|
|
248
|
+
}
|
|
249
|
+
return out.sort((a, b) => a.at - b.at)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const view = (c) => ({ id: c.id, pattern: c.pattern, square: squareShort(c.square_id), square_id: c.square_id, line: c.line, holder: c.holder, note: c.note || '', at: c.at, ...(c.auto ? { auto: true } : {}), ...(c.owner ? { owner: true } : {}), ...(c.expires_at ? { expires_at: c.expires_at } : {}) })
|
|
253
|
+
|
|
254
|
+
// A claim made with `expires_in` (seconds) lets go by itself then (06 `expires_at`): from a minute to a week.
|
|
255
|
+
const MIN_EXPIRY = 60
|
|
256
|
+
const MAX_EXPIRY = 7 * 24 * 3600
|
|
257
|
+
function expiresIn (v) {
|
|
258
|
+
if (v === undefined || v === null || v === '') return 0
|
|
259
|
+
const n = Number(v)
|
|
260
|
+
if (!Number.isInteger(n) || n < MIN_EXPIRY || n > MAX_EXPIRY) throw new QuiltError(400, 'bad_request', `expires_in is whole seconds from ${MIN_EXPIRY} to ${MAX_EXPIRY} (a week)`)
|
|
261
|
+
return n
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 06 "Reuse": today's claim-request limits.
|
|
265
|
+
export const MAX_QUEUE = 20
|
|
266
|
+
export const MAX_REQUEST_TITLE = 120
|
|
267
|
+
export const MAX_REQUEST_TEXT = 300
|
|
268
|
+
export const MAX_HANDOFF_TEXT = 2000
|
|
269
|
+
|
|
270
|
+
const requestView = (r) => ({ id: r.id, by: r.by, square: squareShort(r.by_square), square_id: r.by_square, title: r.title, description: r.description || '', at: r.at })
|
|
271
|
+
const queueOf = (requests, c) => requests.filter((r) => r.square_id === c.square_id && r.pattern === c.pattern)
|
|
272
|
+
|
|
273
|
+
export function claimOps (svc) {
|
|
274
|
+
const { store, now, quiltFor, squareByRef, writable, entriesOf, snapshot, fromStore, claimIdleMs, memberName } = svc.internals
|
|
275
|
+
// TODO 0.6: a claim names its holder by name too (holder_name), the id staying in holder.
|
|
276
|
+
const named = async (v) => { const n = memberName ? await memberName(v.holder) : null; return n ? { ...v, holder_name: n } : v }
|
|
277
|
+
|
|
278
|
+
/** GET /claims[?square=]: the claims that hold now, oldest first. */
|
|
279
|
+
async function claims (me, ref, { square } = {}) {
|
|
280
|
+
const q = await quiltFor(me, ref, 'read')
|
|
281
|
+
let rows = await liveClaims(store, q.id, now(), claimIdleMs)
|
|
282
|
+
if (square) { const sq = await squareByRef(q.id, square); rows = rows.filter((c) => c.square_id === sq.id) }
|
|
283
|
+
const requests = await store.claimRequests(q.id)
|
|
284
|
+
return { claims_version: await store.claimsVersion(q.id), claims: await Promise.all(rows.sort((a, b) => a.at - b.at).map((c) => { const queue = queueOf(requests, c); return named({ ...view(c), ...(queue.length ? { queue: queue.map(requestView) } : {}) }) })) }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** POST /squares/:id/claims { pattern, note? }: claims paths for the square. */
|
|
288
|
+
async function claim (me, ref, sqRef, body = {}) {
|
|
289
|
+
const q = await quiltFor(me, ref, 'write')
|
|
290
|
+
const sq = await writable(me, q, sqRef)
|
|
291
|
+
if (sq.state !== 'open') throw new QuiltError(409, 'square_closed', `the square is ${sq.state}`)
|
|
292
|
+
const pattern = claimPattern(body.pattern)
|
|
293
|
+
const note = String(body.note || '')
|
|
294
|
+
if (note.length > MAX_NOTE) throw new QuiltError(400, 'bad_request', `a claim's note is up to ${MAX_NOTE} characters`)
|
|
295
|
+
const ttl = expiresIn(body.expires_in)
|
|
296
|
+
const at = now()
|
|
297
|
+
const all = await liveClaims(store, q.id, at, claimIdleMs)
|
|
298
|
+
const mine = all.filter((c) => c.square_id === sq.id)
|
|
299
|
+
const same = mine.find((c) => c.pattern === pattern)
|
|
300
|
+
if (same && !same.auto) return view(same)
|
|
301
|
+
if (mine.length >= MAX_CLAIMS) throw new QuiltError(400, 'bad_request', `a square holds up to ${MAX_CLAIMS} claims`)
|
|
302
|
+
const others = all.filter((c) => c.square_id !== sq.id && c.line === sq.line)
|
|
303
|
+
if (others.length) {
|
|
304
|
+
const tip = (await store.getLine(q.id, sq.line))?.tip || sq.base
|
|
305
|
+
const paths = tip ? (await entriesOf(q.id, await snapshot(q.id, tip))).map((e) => e.path) : []
|
|
306
|
+
const taken = others.find((c) => patternsOverlap(c.pattern, pattern, paths))
|
|
307
|
+
if (taken) throw new QuiltError(409, 'claim_taken', `${taken.pattern} is already claimed by ${(memberName && await memberName(taken.holder)) || taken.holder} in ${squareShort(taken.square_id)}${taken.note ? ` (${taken.note})` : ''}`, { claim: view(taken) })
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
// A lapsed claim of this square's is a dead row: clear them so unclaim by pattern finds the live one.
|
|
311
|
+
// Claiming on purpose a path the square holds by an auto-claim makes it a claim made on purpose.
|
|
312
|
+
if (same) await store.deleteClaim({ quilt_id: q.id, id: same.id })
|
|
313
|
+
for (const x of await store.claims(q.id)) if (x.square_id === sq.id && x.expires_at <= at) await store.deleteClaim({ quilt_id: q.id, id: x.id })
|
|
314
|
+
const row = await store.createClaim({ quilt_id: q.id, id: uuidv7(at), square_id: sq.id, pattern, holder: me.member_id, note, at, ...(ttl ? { expires_at: at + ttl * 1000 } : {}) })
|
|
315
|
+
return view({ ...row, line: sq.line })
|
|
316
|
+
} catch (err) { throw fromStore(err) }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** DELETE /squares/:id/claims/:claim (its id or its pattern): lets go of a claim. */
|
|
320
|
+
async function unclaim (me, ref, sqRef, which) {
|
|
321
|
+
const q = await quiltFor(me, ref, 'write')
|
|
322
|
+
const sq = await writable(me, q, sqRef)
|
|
323
|
+
const key = String(which || '').trim().replace(/^\.\//, '')
|
|
324
|
+
const c = (await store.claims(q.id)).find((x) => x.square_id === sq.id && (x.id === key || x.pattern === key))
|
|
325
|
+
if (!c) throw new QuiltError(404, 'not_found', `${squareShort(sq.id)} has no claim ${key}`)
|
|
326
|
+
const waiting = queueOf(await store.claimRequests(q.id), c)
|
|
327
|
+
if (waiting.length) throw new QuiltError(409, 'claim_queued', `${waiting.length} waiting for ${c.pattern} (first: ${waiting[0].by}, "${waiting[0].title}"): hand it over with seam claim handoff ${c.pattern} and a note instead`, { queue: waiting.map(requestView) })
|
|
328
|
+
try { await store.deleteClaim({ quilt_id: q.id, id: c.id }) } catch (err) { throw fromStore(err) }
|
|
329
|
+
return { ok: true, released: view({ ...c, line: sq.line }) }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A live claim on square `sq` by id or pattern, or 404. */
|
|
333
|
+
async function heldClaim (q, sq, which) {
|
|
334
|
+
const key = String(which || '').trim().replace(/^\.\//, '')
|
|
335
|
+
const c = (await liveClaims(store, q.id, now(), claimIdleMs)).find((x) => x.square_id === sq.id && (x.id === key || x.pattern === key))
|
|
336
|
+
if (!c) throw new QuiltError(404, 'not_found', `${squareShort(sq.id)} holds no claim ${key}`)
|
|
337
|
+
return c
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** POST /claims/requests { claim (id or pattern), square, title, description? }: waits in a claim's queue. */
|
|
341
|
+
async function requestClaim (me, ref, body = {}) {
|
|
342
|
+
const q = await quiltFor(me, ref, 'write')
|
|
343
|
+
const mine = await writable(me, q, body.square)
|
|
344
|
+
const title = String(body.title || '').trim()
|
|
345
|
+
const description = String(body.description || '')
|
|
346
|
+
if (!title || title.length > MAX_REQUEST_TITLE) throw new QuiltError(400, 'bad_request', `a request has a title of 1 to ${MAX_REQUEST_TITLE} characters: what you need the files for`)
|
|
347
|
+
if (description.length > MAX_REQUEST_TEXT) throw new QuiltError(400, 'bad_request', `a request's description is up to ${MAX_REQUEST_TEXT} characters`)
|
|
348
|
+
const key = String(body.claim || '').trim().replace(/^\.\//, '')
|
|
349
|
+
const c = (await liveClaims(store, q.id, now(), claimIdleMs)).find((x) => x.line === mine.line && (x.id === key || x.pattern === key) && x.square_id !== mine.id)
|
|
350
|
+
if (!c) throw new QuiltError(400, 'bad_request', `no other square on ${mine.line} holds a claim ${key}`)
|
|
351
|
+
const queue = queueOf(await store.claimRequests(q.id), c)
|
|
352
|
+
const already = queue.find((r) => r.by_square === mine.id)
|
|
353
|
+
if (already) return requestView(already)
|
|
354
|
+
if (queue.length >= MAX_QUEUE) throw new QuiltError(409, 'queue_full', `${MAX_QUEUE} already wait for ${c.pattern}`)
|
|
355
|
+
try {
|
|
356
|
+
const at = now()
|
|
357
|
+
return requestView(await store.addClaimRequest({ quilt_id: q.id, id: uuidv7(at), square_id: c.square_id, pattern: c.pattern, by: me.member_id, by_square: mine.id, title, description, at }))
|
|
358
|
+
} catch (err) { throw fromStore(err) }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** DELETE /claims/requests/:id: takes your request out of the queue. */
|
|
362
|
+
async function withdrawRequest (me, ref, id) {
|
|
363
|
+
const q = await quiltFor(me, ref, 'write')
|
|
364
|
+
const r = (await store.claimRequests(q.id)).find((x) => x.id === id && x.by === me.member_id)
|
|
365
|
+
if (!r) throw new QuiltError(404, 'not_found', `you have no request ${id}`)
|
|
366
|
+
try { await store.deleteClaimRequest({ quilt_id: q.id, id: r.id }) } catch (err) { throw fromStore(err) }
|
|
367
|
+
return { ok: true, withdrawn: requestView(r) }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** POST /squares/:id/claims/handoff { claim, to? (request id), note? }: gives the claim to the first in its queue (or `to`). */
|
|
371
|
+
async function handoffClaim (me, ref, sqRef, body = {}) {
|
|
372
|
+
const q = await quiltFor(me, ref, 'write')
|
|
373
|
+
const sq = await writable(me, q, sqRef)
|
|
374
|
+
const note = String(body.note || '')
|
|
375
|
+
if (note.length > MAX_HANDOFF_TEXT) throw new QuiltError(400, 'bad_request', `a handoff note is up to ${MAX_HANDOFF_TEXT} characters`)
|
|
376
|
+
const c = await heldClaim(q, sq, body.claim)
|
|
377
|
+
const queue = []
|
|
378
|
+
for (const r of queueOf(await store.claimRequests(q.id), c)) if ((await store.getSquare(r.by_square))?.state === 'open') queue.push(r)
|
|
379
|
+
const r = body.to ? queue.find((x) => x.id === body.to) : queue[0]
|
|
380
|
+
if (!r) throw new QuiltError(409, 'no_request', body.to ? `no open request ${body.to} waits for ${c.pattern}` : `nobody waits for ${c.pattern}`)
|
|
381
|
+
try {
|
|
382
|
+
const row = await moveClaim(store, q.id, c, r, note, me.member_id, now())
|
|
383
|
+
return { claim: view({ ...row, line: c.line }), request: requestView(r), from: { square: squareShort(sq.id), holder: c.holder } }
|
|
384
|
+
} catch (err) { throw fromStore(err) }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* POST /squares/:id/claims/finish: the writer says it is finished (06 "Auto-claims following
|
|
389
|
+
* edits"). Lets go of the square's auto-claims now instead of after 10 quiet minutes; one someone
|
|
390
|
+
* waits for goes to the first open square in its queue. Claims made on purpose stay.
|
|
391
|
+
*/
|
|
392
|
+
async function finishClaims (me, ref, sqRef, body = {}) {
|
|
393
|
+
const q = await quiltFor(me, ref, 'write')
|
|
394
|
+
const sq = await writable(me, q, sqRef)
|
|
395
|
+
const note = String(body.note || '')
|
|
396
|
+
if (note.length > MAX_HANDOFF_TEXT) throw new QuiltError(400, 'bad_request', `a handoff note is up to ${MAX_HANDOFF_TEXT} characters`)
|
|
397
|
+
const auto = (await liveClaims(store, q.id, now(), claimIdleMs)).filter((c) => c.square_id === sq.id && c.auto).sort((a, b) => a.at - b.at)
|
|
398
|
+
const released = []
|
|
399
|
+
const handed = []
|
|
400
|
+
for (const c of auto) {
|
|
401
|
+
try { handed.push(await handoffClaim(me, ref, sq.id, { claim: c.id, ...(note ? { note } : {}) })); continue } catch (err) { if (err.code !== 'no_request') throw err }
|
|
402
|
+
try { await store.deleteClaim({ quilt_id: q.id, id: c.id }) } catch (err) { throw fromStore(err) }
|
|
403
|
+
released.push(view(c))
|
|
404
|
+
}
|
|
405
|
+
return { ok: true, released, handed }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return { claims, claim, unclaim, requestClaim, withdrawRequest, handoffClaim, finishClaims }
|
|
409
|
+
}
|