heyquilt 0.3.120 → 0.3.122
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/RELEASES.md +23 -0
- package/bin/quilt.js +23 -2
- package/package.json +2 -1
- package/src/api/memory-store.js +5 -1
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +36 -0
- package/src/api/server.js +37 -9
- package/src/api/supabase-store.js +10 -0
- package/src/connection.js +4 -1
- package/src/control.js +12 -0
- package/src/history.js +15 -1
- package/src/mcp.js +12 -1
- package/src/quilts/accounts.js +136 -0
- package/src/quilts/affected.js +132 -0
- package/src/quilts/checks.js +308 -0
- package/src/quilts/cli.js +97 -10
- package/src/quilts/client.js +9 -0
- package/src/quilts/describe.js +31 -1
- package/src/quilts/events.js +13 -2
- package/src/quilts/export-remote.js +156 -0
- package/src/quilts/import-follow.js +236 -0
- package/src/quilts/import-git.js +6 -3
- package/src/quilts/import.js +67 -5
- package/src/quilts/index.js +28 -6
- package/src/quilts/keys.js +186 -0
- package/src/quilts/merge.js +16 -1
- package/src/quilts/oauth.js +202 -0
- package/src/quilts/reviews.js +18 -5
- package/src/quilts/routes.js +61 -1
- package/src/quilts/runner.js +95 -0
- package/src/quilts/server.js +144 -0
- package/src/quilts/service.js +41 -1
- package/src/quilts/setup.js +9 -2
- package/src/quilts/store-memory.js +164 -3
- package/src/quilts/store-rpc.js +12 -1
- package/src/quilts/tools.js +39 -11
- package/src/quilts/window.js +2 -0
- package/src/relay-mcp.js +33 -3
- package/src/relay-prune.js +4 -1
- package/src/runner.js +2 -2
- package/src/server.js +317 -14
- package/src/session-follow.js +81 -0
- package/src/session-layout3.js +60 -0
- package/src/session-migrate.js +119 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +71 -0
- package/src/session.js +85 -3
- package/src/source-live/live-block.js +4 -3
- package/src/source-live/live-square.js +1 -1
- package/src/tool-guess.js +45 -0
- package/src/ui/home.js +2 -1
- package/src/ui-server.js +6 -16
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Seam's own accounts: a person signs in on seam.heyquilt.com with Supabase Auth in Seam's own
|
|
2
|
+
// project, and Seam's API checks the access token itself (no other service is asked): the
|
|
3
|
+
// signature against the project's published keys (JWKS, ES256/RS256) or its shared secret
|
|
4
|
+
// (HS256), the issuer, the audience and the expiry. The first call makes the account's row
|
|
5
|
+
// (store.accountFor), whose member_id is who the person is in every quilt.
|
|
6
|
+
import crypto from 'node:crypto'
|
|
7
|
+
import { QuiltError } from './errors.js'
|
|
8
|
+
import { isSeamKey } from './keys.js'
|
|
9
|
+
import { isOAuthToken } from './oauth.js'
|
|
10
|
+
|
|
11
|
+
const b64 = (s) => Buffer.from(String(s), 'base64url')
|
|
12
|
+
const ALGS = { ES256: 'sha256', RS256: 'sha256', HS256: 'sha256' }
|
|
13
|
+
export const isJwt = (s) => /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(String(s || ''))
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* verify(token) → the token's claims when Seam's Supabase Auth signed it and it is current, else
|
|
17
|
+
* null. `authUrl` is the project's address (https://<ref>.supabase.co); `jwtSecret` the legacy
|
|
18
|
+
* shared secret, when the project still signs with one.
|
|
19
|
+
*/
|
|
20
|
+
export function supabaseVerifier ({ authUrl, jwtSecret = null, fetch = globalThis.fetch, now = Date.now, keysTtlMs = 10 * 60_000, timeoutMs = 5000 }) {
|
|
21
|
+
if (!authUrl) throw new Error('supabaseVerifier needs the auth project address (SEAM_AUTH_URL)')
|
|
22
|
+
const base = String(authUrl).replace(/\/+$/, '')
|
|
23
|
+
const issuer = `${base}/auth/v1`
|
|
24
|
+
let keys = null // { at, byKid: Map }
|
|
25
|
+
let lastMiss = 0
|
|
26
|
+
|
|
27
|
+
async function loadKeys () {
|
|
28
|
+
let res
|
|
29
|
+
try { res = await fetch(`${issuer}/.well-known/jwks.json`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) }) } catch { throw new QuiltError(503, 'identity_unavailable', 'Seam could not reach its sign-in keys; try again') }
|
|
30
|
+
const data = await res.json().catch(() => null)
|
|
31
|
+
if (!res.ok || !data || !Array.isArray(data.keys)) throw new QuiltError(503, 'identity_unavailable', 'Seam could not read its sign-in keys; try again')
|
|
32
|
+
const byKid = new Map()
|
|
33
|
+
for (const jwk of data.keys) {
|
|
34
|
+
try { byKid.set(jwk.kid || '', { alg: jwk.alg, key: crypto.createPublicKey({ key: jwk, format: 'jwk' }) }) } catch {}
|
|
35
|
+
}
|
|
36
|
+
keys = { at: now(), byKid }
|
|
37
|
+
return keys
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function keyFor (kid) {
|
|
41
|
+
if (!keys || now() - keys.at > keysTtlMs) await loadKeys()
|
|
42
|
+
let k = keys.byKid.get(kid || '')
|
|
43
|
+
// A key rotated in since the last fetch: fetch again, at most once a minute.
|
|
44
|
+
if (!k && now() - lastMiss > 60_000) { lastMiss = now(); await loadKeys(); k = keys.byKid.get(kid || '') }
|
|
45
|
+
return k || null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return async function verify (token) {
|
|
49
|
+
if (!isJwt(token)) return null
|
|
50
|
+
const [h, p, s] = String(token).split('.')
|
|
51
|
+
let header, claims
|
|
52
|
+
try { header = JSON.parse(b64(h).toString('utf8')); claims = JSON.parse(b64(p).toString('utf8')) } catch { return null }
|
|
53
|
+
if (!header || !claims || !ALGS[header.alg]) return null
|
|
54
|
+
const data = Buffer.from(`${h}.${p}`)
|
|
55
|
+
const sig = b64(s)
|
|
56
|
+
let ok = false
|
|
57
|
+
if (header.alg === 'HS256') {
|
|
58
|
+
if (!jwtSecret) return null
|
|
59
|
+
const want = crypto.createHmac('sha256', jwtSecret).update(data).digest()
|
|
60
|
+
ok = want.length === sig.length && crypto.timingSafeEqual(want, sig)
|
|
61
|
+
} else {
|
|
62
|
+
const k = await keyFor(header.kid)
|
|
63
|
+
if (!k) return null
|
|
64
|
+
try { ok = crypto.verify('sha256', data, header.alg === 'ES256' ? { key: k.key, dsaEncoding: 'ieee-p1363' } : k.key, sig) } catch { ok = false }
|
|
65
|
+
}
|
|
66
|
+
if (!ok) return null
|
|
67
|
+
const t = Math.floor(now() / 1000)
|
|
68
|
+
if (typeof claims.exp !== 'number' || claims.exp <= t) return null
|
|
69
|
+
if (claims.iss !== issuer) return null
|
|
70
|
+
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud]
|
|
71
|
+
if (!aud.includes('authenticated') || claims.role !== 'authenticated') return null
|
|
72
|
+
if (typeof claims.sub !== 'string' || !/^[0-9a-f-]{36}$/i.test(claims.sub) || !claims.email) return null
|
|
73
|
+
return claims
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const nameOf = (claims) => {
|
|
78
|
+
const m = claims.user_metadata || {}
|
|
79
|
+
const n = String(m.name || m.full_name || String(claims.email).split('@')[0] || 'person').trim()
|
|
80
|
+
return n.slice(0, 200) || 'person'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function accountOps (svc) {
|
|
84
|
+
const { store, now } = svc.internals
|
|
85
|
+
|
|
86
|
+
/** The member a verified sign-in stands for (its account row made on the first call). */
|
|
87
|
+
async function accountCaller (claims) {
|
|
88
|
+
const a = await store.accountFor({ id: claims.sub, email: String(claims.email).toLowerCase(), name: nameOf(claims), at: now() })
|
|
89
|
+
return { member_id: a.member_id, kind: 'person', name: a.name, account: a.id }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** GET /v1/me: who the caller is to Seam. */
|
|
93
|
+
async function whoami (me) {
|
|
94
|
+
const out = { member_id: me.member_id, kind: me.kind, name: me.name }
|
|
95
|
+
if (me.tool) out.tool = me.tool
|
|
96
|
+
if (me.on_behalf_of) out.on_behalf_of = me.on_behalf_of
|
|
97
|
+
if (me.account) {
|
|
98
|
+
const a = await store.getAccount(me.account)
|
|
99
|
+
if (a) out.account = { id: a.id, email: a.email, created_at: a.created_at }
|
|
100
|
+
}
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { accountCaller, whoami }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* identify(req) for Seam's own server: a Seam sign-in (verified here) is the caller; anything
|
|
109
|
+
* else goes to `fallback` (an identity service over HTTP, server.js remoteIdentity) when one is
|
|
110
|
+
* set, and is refused otherwise.
|
|
111
|
+
*/
|
|
112
|
+
export function seamIdentity ({ quilts, verify = null, fallback = null }) {
|
|
113
|
+
if (!verify && !fallback && !quilts?.keyCaller) throw new Error('seamIdentity needs Seam\'s own sign-in (SEAM_AUTH_URL) or an identity service (SEAM_IDENTITY_URL)')
|
|
114
|
+
return async function identify (req) {
|
|
115
|
+
const h = String(req.headers?.authorization || '')
|
|
116
|
+
if (!h) throw new QuiltError(401, 'not_signed_in', 'sign in: send "Authorization: Bearer <token>"')
|
|
117
|
+
const token = h.startsWith('Bearer ') ? h.slice(7).trim() : ''
|
|
118
|
+
if (isSeamKey(token)) {
|
|
119
|
+
const k = await quilts.keyCaller(token)
|
|
120
|
+
if (!k) throw new QuiltError(401, 'bad_key', 'this Seam key is unknown, expired or revoked: run seam login (or ask for a new agent key)')
|
|
121
|
+
return k
|
|
122
|
+
}
|
|
123
|
+
if (isOAuthToken(token)) {
|
|
124
|
+
const o = await quilts.oauthCaller(token)
|
|
125
|
+
if (!o) throw new QuiltError(401, 'invalid_token', 'this access token is unknown, expired or revoked: refresh it')
|
|
126
|
+
return o
|
|
127
|
+
}
|
|
128
|
+
if (verify && isJwt(token)) {
|
|
129
|
+
const claims = await verify(token)
|
|
130
|
+
if (claims) return quilts.accountCaller(claims)
|
|
131
|
+
if (!fallback) throw new QuiltError(401, 'not_signed_in', 'this sign-in is unknown or expired; sign in again')
|
|
132
|
+
}
|
|
133
|
+
if (fallback) return fallback(req)
|
|
134
|
+
throw new QuiltError(401, 'not_signed_in', 'this sign-in is unknown or expired; sign in again')
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Test targeting (plans/quilt-source-control/13 "Test impact from the derived dependency graph").
|
|
2
|
+
// Which of a line's tests (its policy's `tests` globs) a change reaches: a test is affected when
|
|
3
|
+
// the files it imports, followed to depth 8, include a changed path. Grammars: JS/TS (import,
|
|
4
|
+
// export … from, dynamic import, require) and Python (import, from … import, relative or from a
|
|
5
|
+
// root). Bare package names are not followed: a dependency change shows in a lockfile or build
|
|
6
|
+
// file, and those make the answer `confidence: 'all'`, as do a ci-config path, a changed file in a
|
|
7
|
+
// language without a grammar, and a graph above the bounds. The runner runs everything for 'all';
|
|
8
|
+
// the post-merge tier always gets 'all', so a wrong answer here is caught by the next batch.
|
|
9
|
+
import { globMatcher } from './claims.js'
|
|
10
|
+
|
|
11
|
+
export const MAX_DEPTH = 8
|
|
12
|
+
export const MAX_FILES = 5000
|
|
13
|
+
const MAX_TESTS = 500
|
|
14
|
+
const MAX_READ = 512 * 1024
|
|
15
|
+
|
|
16
|
+
const LOCKFILE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|go\.sum|poetry\.lock|Pipfile\.lock|uv\.lock|Gemfile\.lock|composer\.lock)$/
|
|
17
|
+
const BUILD = /(^|\/)(package\.json|pnpm-workspace\.yaml|go\.mod|Cargo\.toml|pyproject\.toml|setup\.py|setup\.cfg|Pipfile|requirements[^/]*\.txt|Gemfile|tsconfig[^/]*\.json)$/
|
|
18
|
+
export const DEFAULT_CI_CONFIG = ['.github/workflows/**', '.gitlab-ci.yml', '.circleci/**', '.buildkite/**', 'Jenkinsfile']
|
|
19
|
+
const JS = /\.(js|mjs|cjs|jsx|ts|mts|cts|tsx)$/
|
|
20
|
+
const PY = /\.py$/
|
|
21
|
+
// Files a grammar can import or that no code runs: reachable through imports, or reaching nothing.
|
|
22
|
+
const DATA = /\.(json|ya?ml|toml|css|scss|sass|less|html?|svg|png|jpe?g|gif|webp|ico|woff2?|md|mdx|txt|rst|csv|lock)$|(^|\/)(LICENSE|README|CHANGELOG|\.gitignore|\.gitattributes|\.editorconfig|\.npmignore)[^/]*$/i
|
|
23
|
+
const JS_EXT = ['', '.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '.json']
|
|
24
|
+
|
|
25
|
+
const dirOf = (p) => p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : ''
|
|
26
|
+
function join (dir, rel) {
|
|
27
|
+
const out = dir ? dir.split('/') : []
|
|
28
|
+
for (const part of rel.split('/')) {
|
|
29
|
+
if (part === '' || part === '.') continue
|
|
30
|
+
if (part === '..') { if (!out.length) return null; out.pop() } else out.push(part)
|
|
31
|
+
}
|
|
32
|
+
return out.join('/')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The module names a file imports, as written (relative JS specifiers, Python dotted names). */
|
|
36
|
+
export function importsOf (path, text) {
|
|
37
|
+
const out = []
|
|
38
|
+
if (JS.test(path)) {
|
|
39
|
+
const rx = /(?:^|[^\w$.])(?:import|export)\s[^'"`;]*?\bfrom\s*['"]([^'"]+)['"]|(?:^|[^\w$.])import\s*\(?\s*['"]([^'"]+)['"]|(?:^|[^\w$.])require\s*\(\s*['"]([^'"]+)['"]/g
|
|
40
|
+
for (const m of text.matchAll(rx)) out.push(m[1] || m[2] || m[3])
|
|
41
|
+
} else if (PY.test(path)) {
|
|
42
|
+
for (const line of text.split('\n')) {
|
|
43
|
+
let m = /^\s*from\s+(\.*[\w.]*)\s+import\s+(.+)/.exec(line)
|
|
44
|
+
if (m) {
|
|
45
|
+
if (/^\.+$/.test(m[1])) for (const name of m[2].replace(/[()]/g, '').split(',')) { const n = name.trim().split(/\s+/)[0]; if (/^\w+$/.test(n)) out.push(m[1] + n) } else out.push(m[1])
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
m = /^\s*import\s+(.+)/.exec(line)
|
|
49
|
+
if (m) for (const name of m[1].split(',')) { const n = name.trim().split(/\s+/)[0]; if (/^[\w.]+$/.test(n)) out.push(n) }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The paths a module name can be, in order (the first that exists is the import). */
|
|
56
|
+
function candidates (from, spec, roots) {
|
|
57
|
+
if (JS.test(from)) {
|
|
58
|
+
if (!spec.startsWith('.')) return []
|
|
59
|
+
const base = join(dirOf(from), spec)
|
|
60
|
+
if (base == null) return []
|
|
61
|
+
const out = JS_EXT.map((e) => base + e).concat(JS_EXT.slice(1).map((e) => base + '/index' + e))
|
|
62
|
+
if (/\.(m|c)?js$/.test(base)) out.push(...['.ts', '.tsx', '.mts', '.cts'].map((e) => base.replace(/\.(m|c)?js$/, e)))
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
const dots = /^\.*/.exec(spec)[0].length
|
|
66
|
+
const rest = spec.slice(dots).split('.').filter(Boolean).join('/')
|
|
67
|
+
let bases
|
|
68
|
+
if (dots) {
|
|
69
|
+
let dir = dirOf(from)
|
|
70
|
+
for (let i = 1; i < dots; i++) dir = dirOf(dir)
|
|
71
|
+
bases = [dir ? (rest ? dir + '/' + rest : dir) : rest]
|
|
72
|
+
} else bases = [...roots.map((r) => (r ? r + '/' : '') + rest), (dirOf(from) ? dirOf(from) + '/' : '') + rest]
|
|
73
|
+
return bases.filter(Boolean).flatMap((b) => [b + '.py', b + '/__init__.py'])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* { confidence: 'paths', tests: [...] } or { confidence: 'all', why }.
|
|
78
|
+
* changed: the changed paths (deleted ones too); files: every path at the new snapshot;
|
|
79
|
+
* read(path) → text; tests / ci_config: globs; roots: where absolute Python imports start.
|
|
80
|
+
*/
|
|
81
|
+
export async function affectedTests ({ changed, files, read, tests = [], ci_config: ciConfig = [], roots = ['', 'src', 'lib'], maxFiles = MAX_FILES, maxDepth = MAX_DEPTH }) {
|
|
82
|
+
const all = (why) => ({ confidence: 'all', why })
|
|
83
|
+
if (!tests.length) return all('the line names no tests')
|
|
84
|
+
const ci = [...DEFAULT_CI_CONFIG, ...ciConfig].map(globMatcher)
|
|
85
|
+
for (const p of changed) {
|
|
86
|
+
if (LOCKFILE.test(p)) return all(`${p} is a lockfile`)
|
|
87
|
+
if (BUILD.test(p)) return all(`${p} declares dependencies`)
|
|
88
|
+
if (ci.some((m) => m(p))) return all(`${p} is ci config`)
|
|
89
|
+
if (!JS.test(p) && !PY.test(p) && !DATA.test(p)) return all(`${p} is in a language Seam has no import grammar for`)
|
|
90
|
+
}
|
|
91
|
+
if (files.length > maxFiles) return all(`more than ${maxFiles} files`)
|
|
92
|
+
const isTest = tests.map(globMatcher)
|
|
93
|
+
const present = new Set(files)
|
|
94
|
+
const exists = new Set([...files, ...changed])
|
|
95
|
+
const hit = new Set(changed)
|
|
96
|
+
const edges = new Map()
|
|
97
|
+
let reads = 0
|
|
98
|
+
const importsFrom = async (p) => {
|
|
99
|
+
if (edges.has(p)) return edges.get(p)
|
|
100
|
+
let out = []
|
|
101
|
+
if ((JS.test(p) || PY.test(p)) && present.has(p)) {
|
|
102
|
+
if (++reads > maxFiles) throw Object.assign(new Error('bound'), { bound: true })
|
|
103
|
+
const text = String(await read(p)).slice(0, MAX_READ)
|
|
104
|
+
for (const spec of importsOf(p, text)) {
|
|
105
|
+
const to = candidates(p, spec, roots).find((c) => exists.has(c))
|
|
106
|
+
if (to && to !== p) out.push(to)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
edges.set(p, out)
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
const reaches = async (start) => {
|
|
113
|
+
let frontier = [start]
|
|
114
|
+
const seen = new Set(frontier)
|
|
115
|
+
for (let depth = 0; frontier.length && depth <= maxDepth; depth++) {
|
|
116
|
+
if (frontier.some((p) => hit.has(p))) return true
|
|
117
|
+
const next = []
|
|
118
|
+
for (const p of frontier) for (const q of await importsFrom(p)) if (!seen.has(q)) { seen.add(q); next.push(q) }
|
|
119
|
+
frontier = next
|
|
120
|
+
}
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
const out = []
|
|
124
|
+
try {
|
|
125
|
+
for (const p of [...present].sort()) if (isTest.some((m) => m(p)) && await reaches(p)) out.push(p)
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err.bound) return all(`more than ${maxFiles} files to read`)
|
|
128
|
+
throw err
|
|
129
|
+
}
|
|
130
|
+
if (out.length > MAX_TESTS) return all(`more than ${MAX_TESTS} tests`)
|
|
131
|
+
return { confidence: 'paths', tests: out }
|
|
132
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Check runs (plans/quilt-source-control/13 "Checks"). Seam never runs a check: a line's policy
|
|
2
|
+
// names each check's `runners` (members), and Seam asks them to run it with a check.requested
|
|
3
|
+
// event and a run record keyed by `run_key`:
|
|
4
|
+
// - pre_merge, on a square's new head, for each check its line requires (requested_by 'policy');
|
|
5
|
+
// - pre_merge, when a square asks to merge and a required check has no run on its head ('holder');
|
|
6
|
+
// - post_merge, on the line's new tip after a merge, for every check with runners ('policy').
|
|
7
|
+
// Runners get the event by webhook (events.js) or poll GET /checks?state=queued, run the check and
|
|
8
|
+
// report with POST /squares/:s/checks or /lines/:l/checks (optionally naming the run_key). A
|
|
9
|
+
// verdict from a member who is not one of the check's runners is advisory: kept on a run of its
|
|
10
|
+
// own, it gates nothing.
|
|
11
|
+
import { QuiltError, squareShort } from './service.js'
|
|
12
|
+
import { uuidv7 } from './ids.js'
|
|
13
|
+
import { affectedTests } from './affected.js'
|
|
14
|
+
|
|
15
|
+
export const TIERS = ['pre_merge', 'post_merge']
|
|
16
|
+
export const RUN_STATES = ['queued', 'running', 'done', 'cancelled']
|
|
17
|
+
export const MAX_RUNNERS = 20
|
|
18
|
+
const MAX_PATHS = 200
|
|
19
|
+
|
|
20
|
+
const POST_ALL = { confidence: 'all', why: 'post_merge runs everything' }
|
|
21
|
+
const OPEN = new Set(['queued', 'running'])
|
|
22
|
+
|
|
23
|
+
export const runKey = ({ tier, name, commit }) => `${tier}:${name}:${commit}`
|
|
24
|
+
|
|
25
|
+
/** The runners a line's rule names for a check ([] when none: anyone with write may report). */
|
|
26
|
+
export const runnersOf = (rule, name) => (rule?.runners && Array.isArray(rule.runners[name]) ? rule.runners[name] : [])
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The tiers a check runs in. A policy's `tiers` names them per check; without it a required check
|
|
30
|
+
* runs pre_merge (on the square) and a check with runners runs post_merge (on the line).
|
|
31
|
+
*/
|
|
32
|
+
export function tiersOf (rule, name) {
|
|
33
|
+
if (rule?.tiers && Array.isArray(rule.tiers[name])) return rule.tiers[name]
|
|
34
|
+
return [...(rule?.checks?.includes(name) ? ['pre_merge'] : []), ...(runnersOf(rule, name).length ? ['post_merge'] : [])]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Checks `tiers` from a policy body: { check: ['pre_merge' | 'post_merge', …] }. */
|
|
38
|
+
export function cleanTiers (tiers, nameOk) {
|
|
39
|
+
if (tiers === null) return {}
|
|
40
|
+
if (typeof tiers !== 'object' || Array.isArray(tiers)) throw new QuiltError(400, 'bad_request', `tiers: { "<check>": [${TIERS.map((t) => `"${t}"`).join(', ')}] }`)
|
|
41
|
+
const out = {}
|
|
42
|
+
for (const [name, list] of Object.entries(tiers)) {
|
|
43
|
+
if (!nameOk(name)) throw new QuiltError(400, 'bad_request', `${name || '(empty)'} is not a check name`)
|
|
44
|
+
if (!Array.isArray(list) || list.some((t) => !TIERS.includes(t))) throw new QuiltError(400, 'bad_request', `tiers.${name}: a list of ${TIERS.join(', ')}`)
|
|
45
|
+
out[name] = TIERS.filter((t) => list.includes(t))
|
|
46
|
+
}
|
|
47
|
+
if (Object.keys(out).length > 100) throw new QuiltError(400, 'bad_request', 'tiers: up to 100 checks')
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Checks a list of path globs (`tests`, `ci_config`). */
|
|
52
|
+
export function cleanGlobs (globs, what) {
|
|
53
|
+
if (globs === null) return []
|
|
54
|
+
if (!Array.isArray(globs) || globs.length > 50 || globs.some((g) => typeof g !== 'string' || !g.trim() || g.length > 300 || g.split('/').includes('..'))) {
|
|
55
|
+
throw new QuiltError(400, 'bad_request', `${what}: a list of up to 50 path globs (*, **, ?), e.g. ["test/**", "**/*.test.js"]`)
|
|
56
|
+
}
|
|
57
|
+
return [...new Set(globs.map((g) => g.trim().replace(/^\.\//, '')))]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Checks `runners` from a policy body: { check: [member id, …] }. */
|
|
61
|
+
export function cleanRunners (runners, nameOk) {
|
|
62
|
+
if (runners === null) return {}
|
|
63
|
+
if (typeof runners !== 'object' || Array.isArray(runners)) throw new QuiltError(400, 'bad_request', 'runners: { "<check>": ["<member id>", …] }')
|
|
64
|
+
const out = {}
|
|
65
|
+
for (const [name, list] of Object.entries(runners)) {
|
|
66
|
+
if (!nameOk(name)) throw new QuiltError(400, 'bad_request', `${name || '(empty)'} is not a check name`)
|
|
67
|
+
if (!Array.isArray(list) || list.length > MAX_RUNNERS || list.some((m) => typeof m !== 'string' || !m || m.length > 200)) throw new QuiltError(400, 'bad_request', `runners.${name}: a list of up to ${MAX_RUNNERS} member ids`)
|
|
68
|
+
if (list.length) out[name] = [...new Set(list)]
|
|
69
|
+
}
|
|
70
|
+
if (Object.keys(out).length > 100) throw new QuiltError(400, 'bad_request', 'runners: up to 100 checks')
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A verdict's log link: an http(s) or file address up to 2,000 characters. */
|
|
75
|
+
export function cleanUrl (url) {
|
|
76
|
+
if (url == null || url === '') return null
|
|
77
|
+
if (typeof url !== 'string' || url.length > 2000 || !/^(https?|file):\/\/\S+$/.test(url)) throw new QuiltError(400, 'bad_request', 'url: the log\'s http(s) or file:// address')
|
|
78
|
+
return url
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every check a rule mentions (required, with runners or with tiers), in order. */
|
|
82
|
+
const checkNames = (rule) => [...new Set([...(rule.checks || []), ...Object.keys(rule.runners || {}), ...Object.keys(rule.tiers || {})])]
|
|
83
|
+
|
|
84
|
+
const view = (r) => ({
|
|
85
|
+
id: r.id, run_key: r.run_key, name: r.name, tier: r.tier, state: r.state,
|
|
86
|
+
commit: r.commit, ...(r.square_id ? { square: squareShort(r.square_id), square_id: r.square_id } : {}), line: r.line,
|
|
87
|
+
...(r.patch_no != null ? { patch_no: r.patch_no } : {}),
|
|
88
|
+
requested_by: r.requested_by, runners: r.runners || [],
|
|
89
|
+
...(r.affected ? { affected: r.affected } : {}), ...(r.url ? { url: r.url } : {}), ...(r.verdict ? { verdict: r.verdict } : {}), ...(r.by ? { by: r.by } : {}), ...(r.advisory ? { advisory: true } : {}),
|
|
90
|
+
at: r.at, ...(r.finished_at ? { finished_at: r.finished_at } : {})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
export function checkOps (svc) {
|
|
94
|
+
const { store, now, quiltFor, squareByRef, reader, snapshot, getCommit } = svc.internals
|
|
95
|
+
|
|
96
|
+
const ruleOf = async (qid, line) => {
|
|
97
|
+
const r = (await store.lineRules(qid)).find((x) => x.line === line) || { line, approvals: 0, checks: [], runners: {} }
|
|
98
|
+
return { ...r, ...(r.targeting || {}) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Which tests the change from..to reaches (affected.js), for a pre-merge run. */
|
|
102
|
+
async function affectedFor (qid, rule, from, to, changed) {
|
|
103
|
+
if (!rule.tests?.length) return { confidence: 'all', why: 'the line names no tests' }
|
|
104
|
+
const r = reader(qid)
|
|
105
|
+
const entries = await r.list(await snapshot(qid, to), [''])
|
|
106
|
+
const byPath = new Map(entries.map((e) => [e.path, e]))
|
|
107
|
+
return affectedTests({
|
|
108
|
+
changed, files: entries.map((e) => e.path), tests: rule.tests, ci_config: rule.ci_config || [],
|
|
109
|
+
read: async (p) => (await svc.internals.readBlob(qid, byPath.get(p).blob)).toString('utf8')
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function changedPaths (qid, from, to) {
|
|
114
|
+
if (!to) return []
|
|
115
|
+
return (await reader(qid).diff(await snapshot(qid, from || null), await snapshot(qid, to))).map((e) => e.path)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A run and its check.requested event (the event carries the run so the events job stores it). */
|
|
119
|
+
function request ({ qid, name, tier, commit, square, line, patch_no, runners, requested_by, at, cause_seq, paths, affected, run_key: key }) {
|
|
120
|
+
const run = { id: uuidv7(at), run_key: key || runKey({ tier, name, commit }), name, tier, commit, square_id: square || null, line, patch_no: patch_no ?? null, runners, requested_by, state: 'queued', at, affected }
|
|
121
|
+
return {
|
|
122
|
+
kind: 'check.requested', at, cause_seq, square: square || null, line, paths: paths.slice(0, MAX_PATHS),
|
|
123
|
+
payload: { check: name, tier, commit, run_key: run.run_key, requested_by, affected, ...(patch_no != null ? { patch_no } : {}) },
|
|
124
|
+
recipients: runners, run
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The check.requested events one log row causes (events.js calls this from derive). */
|
|
129
|
+
async function requestedBy (qid, row) {
|
|
130
|
+
const out = []
|
|
131
|
+
// A row written before the line's policy last changed asks nobody (the events job reads the
|
|
132
|
+
// log after the fact; the policy of that moment is the one that counts).
|
|
133
|
+
const before = (rule) => rule.since_seq != null && Number(row.seq) <= Number(rule.since_seq)
|
|
134
|
+
if (row.kind === 'commit' && row.square) {
|
|
135
|
+
const sq = await store.getSquare(row.square)
|
|
136
|
+
if (!sq || sq.import || !['open', 'merging'].includes(sq.state)) return out
|
|
137
|
+
const rule = await ruleOf(qid, sq.line)
|
|
138
|
+
if (before(rule)) return out
|
|
139
|
+
const names = checkNames(rule).filter((n) => runnersOf(rule, n).length && tiersOf(rule, n).includes('pre_merge'))
|
|
140
|
+
if (!names.length) return out
|
|
141
|
+
const paths = await changedPaths(qid, sq.base, row.commit)
|
|
142
|
+
const affected = await affectedFor(qid, rule, sq.base, row.commit, paths)
|
|
143
|
+
for (const name of names) out.push(request({ qid, name, tier: 'pre_merge', commit: row.commit, square: sq.id, line: sq.line, runners: runnersOf(rule, name), requested_by: 'policy', at: row.at, cause_seq: row.seq, paths, affected }))
|
|
144
|
+
} else if (row.kind === 'square' && row.payload?.op === 'checks_requested') {
|
|
145
|
+
const sq = await store.getSquare(row.square)
|
|
146
|
+
if (!sq) return out
|
|
147
|
+
const rule = await ruleOf(qid, sq.line)
|
|
148
|
+
const paths = await changedPaths(qid, sq.base, row.payload.head)
|
|
149
|
+
let affected = null
|
|
150
|
+
for (const name of row.payload.checks || []) {
|
|
151
|
+
const runners = runnersOf(rule, name)
|
|
152
|
+
if (!runners.length) continue
|
|
153
|
+
affected = affected || await affectedFor(qid, rule, sq.base, row.payload.head, paths)
|
|
154
|
+
out.push(request({ qid, name, tier: 'pre_merge', commit: row.payload.head, square: sq.id, line: sq.line, runners, requested_by: 'holder', at: row.at, cause_seq: row.seq, paths, affected }))
|
|
155
|
+
}
|
|
156
|
+
} else if (row.kind === 'policy' && row.payload?.op === 'check_rerun') {
|
|
157
|
+
const p = row.payload
|
|
158
|
+
const rule = await ruleOf(qid, row.line)
|
|
159
|
+
const runners = runnersOf(rule, p.check)
|
|
160
|
+
if (!runners.length) return out
|
|
161
|
+
const sq = p.tier === 'pre_merge' && row.square ? await store.getSquare(row.square) : null
|
|
162
|
+
const paths = sq ? await changedPaths(qid, sq.base, p.commit) : (await getCommit(qid, p.commit)).changes.map((ch) => ch.path)
|
|
163
|
+
const affected = sq ? await affectedFor(qid, rule, sq.base, p.commit, paths) : POST_ALL
|
|
164
|
+
out.push(request({ qid, name: p.check, tier: p.tier, commit: p.commit, square: sq?.id, line: row.line, runners, requested_by: 'rerun', at: row.at, cause_seq: row.seq, paths, affected, run_key: p.run_key }))
|
|
165
|
+
} else if (row.kind === 'merge') {
|
|
166
|
+
const rule = await ruleOf(qid, row.line)
|
|
167
|
+
if (before(rule)) return out
|
|
168
|
+
const names = checkNames(rule).filter((n) => runnersOf(rule, n).length && tiersOf(rule, n).includes('post_merge'))
|
|
169
|
+
if (!names.length) return out
|
|
170
|
+
const c = await getCommit(qid, row.commit)
|
|
171
|
+
const paths = c.changes.map((ch) => ch.path)
|
|
172
|
+
// The post-merge tier always runs everything: a wrong pre-merge `affected` is caught here.
|
|
173
|
+
const affected = POST_ALL
|
|
174
|
+
for (const name of names) out.push(request({ qid, name, tier: 'post_merge', commit: row.commit, square: null, line: row.line, patch_no: row.patch_no, runners: runnersOf(rule, name), requested_by: 'policy', at: row.at, cause_seq: row.seq, paths, affected }))
|
|
175
|
+
}
|
|
176
|
+
return out
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A square asks to merge and a required check with runners has no run on its head: write a
|
|
181
|
+
* log row that asks its runners (once per head: a run that exists is not asked again).
|
|
182
|
+
*/
|
|
183
|
+
async function askForMissing (q, sq, missing, actor) {
|
|
184
|
+
if (!sq.head || !missing.length) return
|
|
185
|
+
if (svc.internals.pumpEvents) await svc.internals.pumpEvents(q) // runs already asked for are stored
|
|
186
|
+
const rule = await ruleOf(q, sq.line)
|
|
187
|
+
const have = new Set((await store.checkRuns(q, { square_id: sq.id, tier: 'pre_merge' })).filter((r) => r.commit === sq.head && !r.advisory).map((r) => r.name))
|
|
188
|
+
const ask = missing.filter((n) => runnersOf(rule, n).length && !have.has(n))
|
|
189
|
+
if (!ask.length) return
|
|
190
|
+
await store.setSquare({ square_id: sq.id, patch: {}, op: 'checks_requested', log: { checks: ask, head: sq.head }, actor, at: now() }).catch(() => {})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Records a reported verdict on its run. Returns { advisory: true } when the reporter is not one
|
|
195
|
+
* of the check's runners (the caller then leaves the gating state alone).
|
|
196
|
+
*/
|
|
197
|
+
async function noteVerdict (me, q, { name, tier, commit, square, line, verdict, run_key: key, url }) {
|
|
198
|
+
const link = cleanUrl(url)
|
|
199
|
+
const rule = await ruleOf(q.id, line)
|
|
200
|
+
const runners = runnersOf(rule, name)
|
|
201
|
+
const counts = !runners.length || runners.includes(me.member_id) || (me.on_behalf_of && runners.includes(me.on_behalf_of))
|
|
202
|
+
const at = now()
|
|
203
|
+
if (!counts) {
|
|
204
|
+
await store.addCheckRuns({ quilt_id: q.id, runs: [{ id: uuidv7(at), run_key: `advisory:${tier}:${name}:${commit}:${me.member_id}`, name, tier, commit, square_id: square || null, line, runners, requested_by: 'holder', state: 'done', verdict, by: me.member_id, advisory: true, at, finished_at: at, ...(link ? { url: link } : {}) }] })
|
|
205
|
+
return { advisory: true }
|
|
206
|
+
}
|
|
207
|
+
let target = key
|
|
208
|
+
if (!target && tier === 'pre_merge') target = runKey({ tier, name, commit })
|
|
209
|
+
if (!target && tier === 'post_merge') {
|
|
210
|
+
const open = (await store.checkRuns(q.id, { tier, name, line })).filter((r) => r.state === 'queued' || r.state === 'running')
|
|
211
|
+
target = open.length ? open.at(-1).run_key : null // the oldest open one (the list is newest first)
|
|
212
|
+
}
|
|
213
|
+
if (target) {
|
|
214
|
+
try { await store.setCheckRun({ quilt_id: q.id, run_key: target, state: 'done', verdict, by: me.member_id, at, ...(link ? { url: link } : {}) }) } catch (err) { if (err.code !== 'not_found') throw err }
|
|
215
|
+
}
|
|
216
|
+
return {}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Cancels what a batch of new requests supersedes (13 "Tiers"): a pre-merge request cancels the
|
|
221
|
+
* open runs of the same check on the same square (an older head, or the same head re-run); a
|
|
222
|
+
* post-merge request on a line keeps the oldest open run and cancels the middle ones (their
|
|
223
|
+
* patches stay after the check's last green, so the next run covers them). A superseded request
|
|
224
|
+
* from the same batch is dropped before anyone hears of it; a stored run gets check.cancelled.
|
|
225
|
+
* Returns the events to write and the stored runs to mark cancelled once they are written.
|
|
226
|
+
*/
|
|
227
|
+
async function settleRuns (qid, events) {
|
|
228
|
+
const cancels = []
|
|
229
|
+
const out = []
|
|
230
|
+
const pending = [] // requests of this batch still standing: [event, run]
|
|
231
|
+
for (const e of events) {
|
|
232
|
+
if (e.kind !== 'check.requested' || !e.run) { out.push(e); continue }
|
|
233
|
+
const r = e.run
|
|
234
|
+
const pre = r.tier === 'pre_merge'
|
|
235
|
+
const same = (x) => x.name === r.name && x.tier === r.tier && x.run_key !== r.run_key && (pre ? x.square_id === r.square_id : x.line === r.line)
|
|
236
|
+
const stored = (await store.checkRuns(qid, { name: r.name, tier: r.tier, ...(pre ? { square_id: r.square_id } : { line: r.line }), limit: 500 }))
|
|
237
|
+
.filter((x) => !x.advisory && OPEN.has(x.state) && same(x) && !cancels.some((c) => c.run_key === x.run_key) && !pending.some(([, b]) => b.run_key === x.run_key))
|
|
238
|
+
.reverse() // oldest first
|
|
239
|
+
const open = [...stored.map((x) => ({ stored: x })), ...pending.filter(([, b]) => same(b)).map(([ev, b]) => ({ ev, b }))]
|
|
240
|
+
for (const v of pre ? open : open.slice(1)) {
|
|
241
|
+
if (v.ev) { pending.splice(pending.findIndex(([ev]) => ev === v.ev), 1); out.splice(out.indexOf(v.ev), 1); continue }
|
|
242
|
+
const x = v.stored
|
|
243
|
+
cancels.push(x)
|
|
244
|
+
out.push({
|
|
245
|
+
kind: 'check.cancelled', at: e.at, cause_seq: e.cause_seq, square: x.square_id || null, line: x.line, paths: [],
|
|
246
|
+
payload: { check: x.name, tier: x.tier, commit: x.commit, run_key: x.run_key, reason: 'superseded', superseded_by: r.run_key },
|
|
247
|
+
recipients: x.runners || []
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
out.push(e)
|
|
251
|
+
pending.push([e, r])
|
|
252
|
+
}
|
|
253
|
+
return { events: out, cancels }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Marks the runs settleRuns cancelled (after their check.cancelled events are written). */
|
|
257
|
+
async function applyCancels (qid, cancels) {
|
|
258
|
+
for (const x of cancels) {
|
|
259
|
+
try { await store.setCheckRun({ quilt_id: qid, run_key: x.run_key, state: 'cancelled', at: now(), expect_state: x.state }) } catch (err) { if (!['not_found', 'square_state'].includes(err.code)) throw err }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* POST /squares/:s/checks/:name/rerun and /lines/:l/checks/:name/rerun: asks the check's runners
|
|
265
|
+
* again, on the square's head (pre_merge) or the line's tip (post_merge), or on `commit`.
|
|
266
|
+
*/
|
|
267
|
+
async function rerunCheck (me, ref, { check, square, line, commit } = {}) {
|
|
268
|
+
const q = await quiltFor(me, ref, 'write')
|
|
269
|
+
const name = String(check || '')
|
|
270
|
+
if (!name || (!square && !line)) throw new QuiltError(400, 'bad_request', 'give the check and a square or a line: seam check rerun <check> --square <square> | --line <line>')
|
|
271
|
+
let sq = null
|
|
272
|
+
let tier, at, lineName
|
|
273
|
+
if (square) {
|
|
274
|
+
sq = await squareByRef(q.id, square)
|
|
275
|
+
tier = 'pre_merge'; lineName = sq.line; at = commit || sq.head
|
|
276
|
+
} else {
|
|
277
|
+
lineName = String(line)
|
|
278
|
+
const ln = await store.getLine(q.id, lineName)
|
|
279
|
+
if (!ln) throw new QuiltError(404, 'not_found', `no line ${lineName}`)
|
|
280
|
+
tier = 'post_merge'; at = commit || ln.tip
|
|
281
|
+
}
|
|
282
|
+
if (!at) throw new QuiltError(409, 'nothing_to_run', `${sq ? squareShort(sq.id) : lineName} has no commit to run ${name} on`)
|
|
283
|
+
const full = (await getCommit(q.id, at)).id
|
|
284
|
+
const rule = await ruleOf(q.id, lineName)
|
|
285
|
+
if (!runnersOf(rule, name).length) throw new QuiltError(409, 'no_runners', `${name} has no runners on ${lineName}: name them with seam line require ${lineName} --runners ${name}=<member>`)
|
|
286
|
+
if (svc.internals.pumpEvents) await svc.internals.pumpEvents(q.id)
|
|
287
|
+
const base = runKey({ tier, name, commit: full })
|
|
288
|
+
const n = (await store.checkRuns(q.id, { name, tier, ...(sq ? { square_id: sq.id } : { line: lineName }), limit: 500 })).filter((r) => r.run_key.startsWith(base + ':rerun')).length + 1
|
|
289
|
+
const key = `${base}:rerun${n}`
|
|
290
|
+
try {
|
|
291
|
+
await store.requestCheckRun({ quilt_id: q.id, line: lineName, square_id: sq?.id || null, actor: me.member_id, at: now(), payload: { op: 'check_rerun', check: name, tier, commit: full, run_key: key } })
|
|
292
|
+
} catch (err) { throw svc.internals.fromStore(err) }
|
|
293
|
+
return { check: name, tier, commit: full, run_key: key, ...(sq ? { square: squareShort(sq.id) } : { line: lineName }) }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** GET /checks?state=&name=&tier=&line=&square=: the quilt's check runs, newest first. */
|
|
297
|
+
async function checkRuns (me, ref, { state, name, tier, line, square, limit = 100 } = {}) {
|
|
298
|
+
const q = await quiltFor(me, ref, 'read')
|
|
299
|
+
if (state && !RUN_STATES.includes(state)) throw new QuiltError(400, 'bad_request', `state is one of ${RUN_STATES.join(', ')}`)
|
|
300
|
+
if (tier && !TIERS.includes(tier)) throw new QuiltError(400, 'bad_request', `tier is one of ${TIERS.join(', ')}`)
|
|
301
|
+
const sq = square ? await squareByRef(q.id, square) : null
|
|
302
|
+
if (svc.internals.pumpEvents) await svc.internals.pumpEvents(q.id) // as GET /events: every run asked for so far is there
|
|
303
|
+
const rows = await store.checkRuns(q.id, { state, name, tier, line, square_id: sq?.id, limit: Math.min(Number(limit) || 100, 500) })
|
|
304
|
+
return { runs: rows.map(view) }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { requestedBy, askForMissing, noteVerdict, checkRuns, settleRuns, applyCancels, rerunCheck }
|
|
308
|
+
}
|