heyquilt 0.3.121 → 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.
Files changed (48) hide show
  1. package/RELEASES.md +19 -0
  2. package/bin/quilt.js +23 -2
  3. package/package.json +2 -1
  4. package/src/api/memory-store.js +5 -1
  5. package/src/api/routes/seam-connect.js +103 -0
  6. package/src/api/routes/session-migrate.js +36 -0
  7. package/src/api/server.js +37 -9
  8. package/src/api/supabase-store.js +10 -0
  9. package/src/connection.js +4 -1
  10. package/src/control.js +12 -0
  11. package/src/history.js +15 -1
  12. package/src/mcp.js +12 -1
  13. package/src/quilts/accounts.js +136 -0
  14. package/src/quilts/affected.js +132 -0
  15. package/src/quilts/checks.js +308 -0
  16. package/src/quilts/cli.js +97 -10
  17. package/src/quilts/client.js +9 -0
  18. package/src/quilts/describe.js +31 -1
  19. package/src/quilts/events.js +13 -2
  20. package/src/quilts/export-remote.js +156 -0
  21. package/src/quilts/import-follow.js +236 -0
  22. package/src/quilts/import-git.js +6 -3
  23. package/src/quilts/import.js +67 -5
  24. package/src/quilts/index.js +28 -6
  25. package/src/quilts/keys.js +186 -0
  26. package/src/quilts/merge.js +16 -1
  27. package/src/quilts/oauth.js +202 -0
  28. package/src/quilts/reviews.js +18 -5
  29. package/src/quilts/routes.js +61 -1
  30. package/src/quilts/runner.js +95 -0
  31. package/src/quilts/server.js +144 -0
  32. package/src/quilts/service.js +41 -1
  33. package/src/quilts/setup.js +9 -2
  34. package/src/quilts/store-memory.js +164 -3
  35. package/src/quilts/store-rpc.js +12 -1
  36. package/src/quilts/tools.js +39 -11
  37. package/src/quilts/window.js +2 -0
  38. package/src/relay-mcp.js +33 -3
  39. package/src/relay-prune.js +4 -1
  40. package/src/server.js +317 -14
  41. package/src/session-follow.js +81 -0
  42. package/src/session-layout3.js +60 -0
  43. package/src/session-migrate.js +119 -0
  44. package/src/session-quilt.js +145 -0
  45. package/src/session-seam.js +71 -0
  46. package/src/session.js +74 -2
  47. package/src/source-live/live-block.js +4 -3
  48. package/src/source-live/live-square.js +1 -1
@@ -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
+ }