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,95 @@
1
+ // seam runner (plans/quilt-source-control/13 "Checks"): a ready-made check runner anyone starts on
2
+ // their own machine. It polls the runs Seam asked its member to run (GET /checks?state=queued),
3
+ // checks each run's commit out into a fresh block folder, runs the command there, and reports the
4
+ // verdict (exit 0 green, anything else red) on the run's run_key with a link to the log. A run
5
+ // Seam cancels (check.cancelled: a newer head superseded it) is stopped and not reported.
6
+ import fs from 'node:fs'
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import { spawn } from 'node:child_process'
10
+ import { checkout } from './block.js'
11
+
12
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
13
+ const safe = (key) => key.replace(/[^\w.-]+/g, '_').slice(0, 180)
14
+
15
+ /** The log link a verdict carries: --log-url with {run_key} and {file}, else the log file's own address. */
16
+ export function logLink (template, runKey, file) {
17
+ if (!template) return 'file://' + file
18
+ return template.replaceAll('{run_key}', encodeURIComponent(runKey)).replaceAll('{file}', encodeURIComponent(path.basename(file)))
19
+ }
20
+
21
+ /** The environment a run's command gets: what to run and, when Seam knows, which tests the change reaches. */
22
+ export function runEnv (run, base = process.env) {
23
+ const a = run.affected || { confidence: 'all' }
24
+ return {
25
+ ...base,
26
+ SEAM_CHECK: run.name, SEAM_TIER: run.tier, SEAM_COMMIT: run.commit, SEAM_RUN_KEY: run.run_key,
27
+ SEAM_LINE: run.line || '', SEAM_SQUARE: run.square || '',
28
+ SEAM_AFFECTED_CONFIDENCE: a.confidence, SEAM_AFFECTED_TESTS: a.confidence === 'all' ? '' : (a.tests || []).join('\n')
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Runs checks for one quilt. { client, quilt, checks: ['ci'], run: 'npm test', dir (where block
34
+ * folders and logs go), api, once (handle what is queued now, then return), intervalMs, logUrl,
35
+ * say(line) }. Returns the runs it finished: [{ run_key, status | 'cancelled', url }].
36
+ */
37
+ export async function runChecks ({ client, quilt, checks, run: command, dir = path.join(os.tmpdir(), 'seam-runner'), api, once = false, intervalMs = 5000, logUrl, say = () => {}, keep = false }) {
38
+ if (!checks?.length) throw new Error('seam runner needs --check <name>[,<name>]')
39
+ if (!command) throw new Error('seam runner needs --run "<command>"')
40
+ const logs = path.join(dir, 'logs')
41
+ fs.mkdirSync(logs, { recursive: true })
42
+ const done = []
43
+ const seen = new Set()
44
+ for (;;) {
45
+ const queued = []
46
+ for (const name of checks) queued.push(...(await client.checkRuns(quilt, { state: 'queued', name })).runs)
47
+ const todo = queued.filter((r) => !seen.has(r.run_key)).sort((a, b) => a.at - b.at)
48
+ for (const r of todo) {
49
+ seen.add(r.run_key)
50
+ done.push(await runOne(r))
51
+ }
52
+ if (once) return done
53
+ await sleep(intervalMs)
54
+ }
55
+
56
+ async function runOne (r) {
57
+ const work = fs.mkdtempSync(path.join(dir, 'run-'))
58
+ const file = path.join(logs, safe(r.run_key) + '.log')
59
+ const url = logLink(logUrl, r.run_key, file)
60
+ say(`${r.name} (${r.tier}) on ${r.commit.slice(0, 12)}${r.square ? ` in ${r.square}` : ` at ${r.line}`}: running`)
61
+ const out = fs.openSync(file, 'w')
62
+ let status
63
+ try {
64
+ fs.writeSync(out, `seam runner: ${r.name} ${r.tier} ${r.commit} run_key ${r.run_key}\n$ ${command}\n`)
65
+ await checkout(client, { quilt, rev: r.commit, dir: work, api })
66
+ const child = spawn(command, { cwd: work, shell: true, env: runEnv(r), stdio: ['ignore', out, out], detached: process.platform !== 'win32' })
67
+ const exited = new Promise((resolve) => child.on('close', (code) => resolve(code)))
68
+ let cancelled = false
69
+ for (;;) {
70
+ const code = await Promise.race([exited, sleep(Math.min(intervalMs, 2000)).then(() => undefined)])
71
+ if (code !== undefined) { status = code === 0 ? 'green' : 'red'; break }
72
+ const gone = (await client.checkRuns(quilt, { state: 'cancelled', name: r.name, limit: 50 })).runs.some((x) => x.run_key === r.run_key)
73
+ if (gone && !cancelled) {
74
+ cancelled = true
75
+ try { process.kill(process.platform !== 'win32' ? -child.pid : child.pid, 'SIGTERM') } catch { child.kill('SIGTERM') }
76
+ }
77
+ if (cancelled) { await exited; status = 'cancelled'; break }
78
+ }
79
+ } catch (err) {
80
+ fs.writeSync(out, `\nseam runner: ${err.message}\n`)
81
+ status = 'red'
82
+ } finally {
83
+ fs.writeSync(out, `\nseam runner: ${status}\n`)
84
+ fs.closeSync(out)
85
+ if (!keep) fs.rmSync(work, { recursive: true, force: true })
86
+ }
87
+ if (status !== 'cancelled') {
88
+ const body = { check: r.name, status, run_key: r.run_key, url }
89
+ if (r.tier === 'pre_merge' && r.square) await client.reportSquareCheck(quilt, r.square, { ...body, head: r.commit })
90
+ else await client.reportCheck(quilt, r.line, body)
91
+ }
92
+ say(`${r.name} on ${r.commit.slice(0, 12)}: ${status} (log ${url})`)
93
+ return { run_key: r.run_key, status, url }
94
+ }
95
+ }
@@ -0,0 +1,144 @@
1
+ // Seam's own server: the routes in routes.js on an HTTP port, apart from any other product.
2
+ // Who is calling comes from an identity service over HTTP (SEAM_IDENTITY_URL): Seam sends it the
3
+ // caller's Authorization header and gets back the caller as a quilt member. Quilt's accounts API is
4
+ // one such service (GET /v1/seam/me); any other host can be one by answering the same way.
5
+ //
6
+ // seam serve or node src/quilts/server.js
7
+ // SEAM_AUTH_URL Seam's own accounts: its Supabase project's address; Seam checks the
8
+ // project's access tokens itself (SEAM_AUTH_JWT_SECRET for a shared secret)
9
+ // SEAM_IDENTITY_URL an identity service, for every other token: GET with the caller's
10
+ // Authorization → 200 { member_id, kind, name, tool?, on_behalf_of? }, or 401
11
+ // (one of the two is required)
12
+ // SEAM_PORT / PORT port (default 8080); SEAM_HOST (default 0.0.0.0)
13
+ // SEAM_STORE=supabase rows and packs in Seam's own Supabase project: SEAM_SUPABASE_URL,
14
+ // SEAM_SUPABASE_KEY (service role), SEAM_BUCKET (default quilt-packs);
15
+ // otherwise a local store in SEAM_DATA (default ./seam-data)
16
+ import http from 'node:http'
17
+ import crypto from 'node:crypto'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { quiltRoutes } from './routes.js'
20
+ import { QuiltError } from './errors.js'
21
+ import { quiltsFromEnv } from './setup.js'
22
+ import { supabaseVerifier, seamIdentity } from './accounts.js'
23
+
24
+ const MAX_BODY = 1024 * 1024
25
+ const KINDS = new Set(['person', 'agent'])
26
+
27
+ /**
28
+ * identify(req) from an identity service over HTTP. A caller it accepts is kept for `ttlMs`
29
+ * (keyed by a hash of the Authorization header, never the header itself); a refusal is not kept.
30
+ */
31
+ export function remoteIdentity ({ url, fetch = globalThis.fetch, ttlMs = 30_000, now = Date.now, max = 10_000, timeoutMs = 5000 }) {
32
+ if (!url) throw new Error('remoteIdentity needs the identity service url (SEAM_IDENTITY_URL)')
33
+ const cache = new Map() // hash → { member, until }
34
+ return async function identify (req) {
35
+ const auth = String(req.headers?.authorization || '')
36
+ if (!auth) throw new QuiltError(401, 'not_signed_in', 'sign in: send "Authorization: Bearer <token>"')
37
+ const key = crypto.createHash('sha256').update(auth).digest('hex')
38
+ const hit = cache.get(key)
39
+ if (hit && hit.until > now()) return hit.member
40
+ let res
41
+ try {
42
+ res = await fetch(url, { headers: { authorization: auth, accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) })
43
+ } catch {
44
+ throw new QuiltError(503, 'identity_unavailable', 'Seam could not reach its sign-in service; try again')
45
+ }
46
+ const data = await res.json().catch(() => null)
47
+ if (res.status === 401 || res.status === 403) throw new QuiltError(401, 'not_signed_in', (data && data.error) || 'this sign-in is unknown or expired')
48
+ const m = res.ok && data && typeof data === 'object' ? data : null
49
+ if (!m || typeof m.member_id !== 'string' || !m.member_id || !KINDS.has(m.kind)) throw new QuiltError(503, 'identity_unavailable', 'Seam\'s sign-in service gave an answer it cannot use; try again')
50
+ const member = { member_id: m.member_id, kind: m.kind, name: String(m.name || m.kind) }
51
+ if (m.tool) member.tool = String(m.tool)
52
+ if (m.on_behalf_of) member.on_behalf_of = String(m.on_behalf_of)
53
+ if (cache.size >= max) cache.delete(cache.keys().next().value)
54
+ cache.set(key, { member, until: now() + ttlMs })
55
+ return member
56
+ }
57
+ }
58
+
59
+ function readBody (req, max, raw) {
60
+ return new Promise((resolve, reject) => {
61
+ let size = 0; const chunks = []
62
+ req.on('data', (c) => { size += c.length; if (size > max) { req.destroy(); reject(new QuiltError(413, 'too_large', 'too large')) } else chunks.push(c) })
63
+ req.on('end', () => {
64
+ const buf = Buffer.concat(chunks)
65
+ if (raw) return resolve(buf)
66
+ if (!buf.length) return resolve({})
67
+ if (/^application\/x-www-form-urlencoded/i.test(String(req.headers['content-type'] || ''))) return resolve(Object.fromEntries(new URLSearchParams(buf.toString('utf8'))))
68
+ let body
69
+ try { body = JSON.parse(buf.toString('utf8')) } catch { return reject(new QuiltError(400, 'bad_request', 'bad json')) }
70
+ resolve(body && typeof body === 'object' && !Array.isArray(body) ? body : {})
71
+ })
72
+ req.on('error', reject)
73
+ })
74
+ }
75
+
76
+ const decode = (s) => { try { return decodeURIComponent(s) } catch { throw new QuiltError(400, 'bad_request', 'bad path') } }
77
+
78
+ /** Seam on a port: { server, url, close }. */
79
+ export async function startSeamServer ({ quilts, identify, port = 0, host = '127.0.0.1', log = () => {} }) {
80
+ if (!quilts) throw new Error('startSeamServer needs quilts (createQuilts)')
81
+ const routes = quiltRoutes({ quilts, identify })
82
+ const server = http.createServer(async (req, res) => {
83
+ const send = (status, data) => {
84
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
85
+ res.end(JSON.stringify(data))
86
+ }
87
+ try {
88
+ const url = new URL(req.url, 'http://x')
89
+ if (req.method === 'GET' && (url.pathname === '/healthz' || url.pathname === '/')) return send(200, { ok: true, service: 'seam' })
90
+ const route = routes.find(([m, re]) => m === req.method && re.test(url.pathname))
91
+ if (!route) return send(404, { ok: false, error: 'not found', code: 'not_found' })
92
+ const opts = route[3] || {}
93
+ const body = opts.raw ? await readBody(req, opts.maxBody || MAX_BODY, true) : ['POST', 'PUT', 'PATCH'].includes(req.method) ? await readBody(req, opts.maxBody || MAX_BODY, false) : {}
94
+ const out = await route[2](req, body, url.pathname.match(route[1]).slice(1).map(decode))
95
+ if (Array.isArray(out)) send(out[0], out[1])
96
+ else send(200, out)
97
+ } catch (err) {
98
+ if (err instanceof QuiltError) return send(err.status, { ok: false, error: err.message, code: err.code, ...err.extra })
99
+ if (err && Number.isInteger(err.status) && err.status >= 400 && err.status < 500) return send(err.status, { ok: false, error: err.message })
100
+ log(`seam error: ${err?.stack || err?.message || err}`)
101
+ send(500, { ok: false, error: 'internal error', code: 'internal' })
102
+ }
103
+ })
104
+ await new Promise((resolve) => server.listen(port, host, resolve))
105
+ const at = server.address()
106
+ return { server, url: `http://${at.address.includes(':') ? `[${at.address}]` : at.address}:${at.port}`, close: () => new Promise((resolve) => server.close(resolve)) }
107
+ }
108
+
109
+ /** The SEAM_* settings as the store settings setup.js reads (its QUILT_SOURCE_* names still work). */
110
+ export function storeEnv (env) {
111
+ const pick = (seam, old) => env[seam] ?? env[old]
112
+ const out = { QUILT_SOURCE: '1' }
113
+ const map = { QUILT_SOURCE_STORE: pick('SEAM_STORE', 'QUILT_SOURCE_STORE'), QUILT_SOURCE_SUPABASE_URL: pick('SEAM_SUPABASE_URL', 'QUILT_SOURCE_SUPABASE_URL'), QUILT_SOURCE_SUPABASE_KEY: pick('SEAM_SUPABASE_KEY', 'QUILT_SOURCE_SUPABASE_KEY'), QUILT_SOURCE_BUCKET: pick('SEAM_BUCKET', 'QUILT_SOURCE_BUCKET'), QUILT_SOURCE_DATA: env.SEAM_DATA ?? env.QUILT_SOURCE_DATA ?? './seam-data', SUPABASE_URL: env.SUPABASE_URL, SEAM_SITE_URL: env.SEAM_SITE_URL }
114
+ for (const [k, v] of Object.entries(map)) if (v !== undefined) out[k] = v
115
+ return out
116
+ }
117
+
118
+ /**
119
+ * Who is calling, from the settings: Seam's own sign-in (SEAM_AUTH_URL, Seam's Supabase project,
120
+ * with SEAM_AUTH_JWT_SECRET when it still signs with a shared secret) checked here, and/or an
121
+ * identity service over HTTP (SEAM_IDENTITY_URL) for every other token.
122
+ */
123
+ export function identityFromEnv (env, quilts, { fetch = globalThis.fetch } = {}) {
124
+ const authUrl = env.SEAM_AUTH_URL || ''
125
+ const verify = authUrl ? supabaseVerifier({ authUrl, jwtSecret: env.SEAM_AUTH_JWT_SECRET || null, fetch }) : null
126
+ const fallback = env.SEAM_IDENTITY_URL ? remoteIdentity({ url: env.SEAM_IDENTITY_URL, fetch, ...(env.SEAM_IDENTITY_TTL_MS ? { ttlMs: Number(env.SEAM_IDENTITY_TTL_MS) } : {}) }) : null
127
+ if (!verify && !fallback) throw new Error('seam serve needs a sign-in: SEAM_AUTH_URL (Seam\'s own accounts, its Supabase project) and/or SEAM_IDENTITY_URL (an identity service, e.g. https://api.heyquilt.com/v1/seam/me)')
128
+ return seamIdentity({ quilts, verify, fallback })
129
+ }
130
+
131
+ /** `seam serve`: the settings from the environment, a port, and a log line. */
132
+ export async function serveFromEnv (env = process.env, { log = console.log } = {}) {
133
+ if (!env.SEAM_AUTH_URL && !env.SEAM_IDENTITY_URL) identityFromEnv(env, null)
134
+ const quilts = await quiltsFromEnv(storeEnv(env), { log })
135
+ const identify = identityFromEnv(env, quilts)
136
+ const s = await startSeamServer({ quilts, identify, port: Number(env.SEAM_PORT || env.PORT || 8080), host: env.SEAM_HOST || '0.0.0.0', log })
137
+ const from = [env.SEAM_AUTH_URL && `Seam accounts (${new URL(env.SEAM_AUTH_URL).host})`, env.SEAM_IDENTITY_URL && new URL(env.SEAM_IDENTITY_URL).host].filter(Boolean).join(' and ')
138
+ log(`seam: listening on ${s.url}, sign-in from ${from}`)
139
+ return s
140
+ }
141
+
142
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
143
+ serveFromEnv().catch((err) => { console.error(err.message || err); process.exit(1) })
144
+ }
@@ -31,7 +31,7 @@ export const shortId = (id, n = 12) => (id ? id.slice(0, n) : null)
31
31
 
32
32
  const statusOf = {
33
33
  not_found: 404, head_moved: 409, tip_moved: 409, square_merging: 409, square_closed: 409, holder_moved: 409, square_state: 409, exists: 409, slug_taken: 409,
34
- conflict: 422, line_frozen: 423, line_exists: 409, tag_exists: 409, claim_refused: 412, claim_taken: 409, policy: 428, own_square: 403, parent_open: 409, bad_parent: 422, bad_request: 400, forbidden: 403, not_holder: 403, too_many_files: 422, pack_corrupt: 422, key_exists: 409
34
+ conflict: 422, line_frozen: 423, line_exists: 409, tag_exists: 409, claim_refused: 412, claim_taken: 409, policy: 428, own_square: 403, parent_open: 409, bad_parent: 422, bad_request: 400, forbidden: 403, not_holder: 403, too_many_files: 422, pack_corrupt: 422, key_exists: 409, moved: 409, too_many_agents: 409, not_claimable: 403
35
35
  }
36
36
 
37
37
  /** A store error as a QuiltError with its wire status. */
@@ -617,6 +617,7 @@ export function createQuiltService ({ store, objects, now = Date.now }) {
617
617
  const path = cleanPath(ch.path)
618
618
  if (path.split('/')[0] === '.seam') throw new QuiltError(400, 'bad_request', '.seam/ is the block\'s own folder and is never committed')
619
619
  if (ch.op === 'delete') { out.push({ path, op: 'delete' }); continue }
620
+ if (ch.op === 'conflict') { out.push(await importedTerm(q, path, ch, blobs)); continue }
620
621
  if (ch.op !== 'put') throw new QuiltError(400, 'bad_request', `${path}: op is put or delete`)
621
622
  let id = ch.blob
622
623
  let size = ch.size
@@ -650,6 +651,45 @@ export function createQuiltService ({ store, objects, now = Date.now }) {
650
651
  return { changes: out, blobs, resolved }
651
652
  }
652
653
 
654
+ /**
655
+ * A conflict brought in from another system (16: an open merge of a session that became a
656
+ * quilt): `{ path, op: 'conflict', reason: 'import', base, ours, theirs }`, each side
657
+ * `{ content (base64), from_square?, from_commit?, by? }` or null for a side without the file.
658
+ * Writes a `content` term (`edit_vs_delete` when a side is gone) whose origin says it was
659
+ * imported; from then on it is a term like a merge's (07): materialised, settled by hand.
660
+ */
661
+ async function importedTerm (q, path, ch, blobs) {
662
+ if (ch.reason !== 'import') throw new QuiltError(400, 'bad_request', `${path}: a commit writes a conflict only with reason 'import'; merges make the others`)
663
+ if (!ch.ours && !ch.theirs) throw new QuiltError(400, 'bad_request', `${path}: a conflict needs at least one side with the file`)
664
+ const side = async (s, which) => {
665
+ if (s == null) return {}
666
+ if (typeof s !== 'object') throw new QuiltError(400, 'bad_request', `${path}: ${which} is { content } or null`)
667
+ const out = {}
668
+ if (s.content != null) {
669
+ const bytes = Buffer.from(String(s.content), 'base64')
670
+ out.blob = blobId(bytes)
671
+ out.mode = 'file'
672
+ blobs.push({ kind: 'blob', id: out.blob, bytes })
673
+ }
674
+ if (s.from_commit != null) {
675
+ if (!isId(s.from_commit) || !(await store.getCommit(q, s.from_commit))) throw new QuiltError(422, 'bad_request', `${path}: ${which}.from_commit is not a commit of this quilt`)
676
+ out.from_commit = s.from_commit
677
+ }
678
+ if (s.from_square != null) {
679
+ const sq = await store.getSquare(String(s.from_square))
680
+ if (!sq || sq.quilt_id !== q) throw new QuiltError(422, 'bad_request', `${path}: ${which}.from_square is not a square of this quilt`)
681
+ out.from_square = sq.id
682
+ }
683
+ if (s.by != null) out.by = String(s.by).slice(0, 200)
684
+ return out
685
+ }
686
+ const [base, ours, theirs] = [await side(ch.base, 'base'), await side(ch.ours, 'ours'), await side(ch.theirs, 'theirs')]
687
+ const term = { kind: ours.blob && theirs.blob ? 'content' : 'edit_vs_delete', adds: [ours, theirs], removes: [base], origin: { reason: 'import' } }
688
+ const id = termId(term)
689
+ blobs.push({ kind: 'term', id, bytes: termBytes(term) })
690
+ return { path, op: 'conflict', term: id }
691
+ }
692
+
653
693
  // ---- uploads (04: have?, then one pack of what is missing) ----
654
694
 
655
695
  async function have (me, ref, ids) {
@@ -6,7 +6,10 @@
6
6
  // QUILT_SOURCE_SUPABASE_KEY (its service role key); the rows from the
7
7
  // *_quilts.sql migration, the packs in its bucket QUILT_SOURCE_BUCKET
8
8
  // (default quilt-packs)
9
+ // SEAM_IMPORT_SCRATCH=<dir> where imports from a git remote keep their mirrors (default the temp folder)
10
+ // SEAM_SECRET_<NAME> a credential an import names (`--credential name`): user:token or a token
9
11
  import path from 'node:path'
12
+ import os from 'node:os'
10
13
  import { createQuilts } from './index.js'
11
14
  import { createMemoryQuiltStore } from './store-memory.js'
12
15
  import { BlobstoreObjects, diskObjects } from './objects.js'
@@ -24,11 +27,15 @@ export async function quiltsFromEnv (env, { memory = false, log = () => {} } = {
24
27
  const store = createSupabaseQuiltStore({ url, serviceKey: key })
25
28
  const objects = new BlobstoreObjects(new SupabaseBucket({ url, key, bucket: env.QUILT_SOURCE_BUCKET || 'quilt-packs' }))
26
29
  log(`quilt source control: rows and packs in ${new URL(url).host}`)
27
- return createQuilts({ store, objects, events: { log } })
30
+ const quilts = createQuilts({ store, objects, events: { log }, siteUrl: env.SEAM_SITE_URL || undefined, importFollow: { scratch: env.SEAM_IMPORT_SCRATCH || path.join(os.tmpdir(), 'seam-imports') } })
31
+ quilts.follower.start()
32
+ return quilts
28
33
  }
29
34
  const dir = path.resolve(env.QUILT_SOURCE_DATA || './quilt-source-data')
30
35
  const store = createMemoryQuiltStore({ file: path.join(dir, 'journal.jsonl') })
31
36
  log(`quilt source control: local store in ${dir}${memory ? '' : ' (QUILT_SOURCE_DATA)'}`)
32
37
  // The local prototype may POST events to webhooks on this machine; the hosted one may not.
33
- return createQuilts({ store, objects: diskObjects(path.join(dir, 'packs')), events: { allowLocalWebhooks: true, log } })
38
+ const quilts = createQuilts({ store, objects: diskObjects(path.join(dir, 'packs')), events: { allowLocalWebhooks: true, log }, siteUrl: env.SEAM_SITE_URL || undefined, importFollow: { scratch: env.SEAM_IMPORT_SCRATCH || path.join(dir, 'imports'), allowLocal: true } })
39
+ quilts.follower.start()
40
+ return quilts
34
41
  }
@@ -23,9 +23,17 @@ export function createMemoryQuiltStore ({ file = null } = {}) {
23
23
  reviews: new Map(), // quilt → review rows (13), oldest first
24
24
  signingKeys: new Map(), // key id → { id, member, public_key, name, added_at, removed_at }
25
25
  signatures: new Map(), // quilt + commit → signature rows
26
+ accounts: new Map(), // account id → { id, email, name, member_id, created_at, seen_at } (accounts.js)
27
+ memberClaims: new Map(), // member id → { member_id, email, name, claimed_by, claimed_at } (members made before Seam accounts)
28
+ agents: new Map(), // agent id → { id, account_id, name, created_at, revoked_at } (keys.js)
29
+ keys: new Map(), // key id → { id, hash, account_id, kind, agent_id, public_key, name, created_at, expires_at, revoked_at }
30
+ oauthClients: new Map(), // client id → { id, account_id, name, redirect_uris, created_at, revoked_at } (oauth.js)
31
+ oauthCodes: new Map(), // code hash → { id, code_hash, client_id, account_id, redirect_uri, challenge, scopes, created_at, expires_at, used_at }
32
+ deviceLinks: new Map(), // link id → { id, code_hash, user_code, public_key, name, platform, state, account_id, created_at, expires_at }
26
33
  tokens: new Map(), // token id → { id, quilt_id, name, level, hash, created_by, created_at, revoked_at } (tokens.js)
27
34
  lineRules: new Map(), // quilt → Map of line → { line, approvals, checks }
28
35
  squareChecks: new Map(), // square → Map of check name → its latest report
36
+ checkRuns: new Map(), // quilt → Map of run_key → a check run (13)
29
37
  claims: new Map(), // quilt → Map of claim id → claim (06: rows, not log entries)
30
38
  resolutions: new Map(), // quilt → Map of key → remembered resolution (07, D12)
31
39
  tags: new Map(), // quilt + name → tag (06: never moved or deleted)
@@ -240,7 +248,7 @@ export function createMemoryQuiltStore ({ file = null } = {}) {
240
248
  if (expect && 'holder' in expect && s.holder !== expect.holder) throw Object.assign(new StoreError('holder_moved', `the square is now held by ${s.holder}`), { holder: s.holder })
241
249
  if (expect && 'state' in expect && s.state !== expect.state) throw Object.assign(new StoreError('square_state', `the square is ${s.state}`), { state: s.state })
242
250
  appendLog(s.quilt_id, { kind: 'square', square: s.id, actor, at, payload: { op, ...patch, ...log } })
243
- Object.assign(s, patch, { updated_at: at })
251
+ Object.assign(s, patch, { updated_at: at }, patch.state === 'merged' ? { closed_at: at } : {})
244
252
  return { ...s }
245
253
  },
246
254
 
@@ -356,6 +364,95 @@ export function createMemoryQuiltStore ({ file = null } = {}) {
356
364
  if (!row.removed_at) row.removed_at = at
357
365
  return clone(row)
358
366
  },
367
+ // A Seam account (accounts.js): made on its first verified call, its email kept current.
368
+ accountFor ({ id, email, name, at }) {
369
+ const row = t.accounts.get(id)
370
+ if (row) { row.email = email; row.seen_at = at; return clone(row) }
371
+ const taken = new Set([...t.accounts.values()].map((a) => a.member_id))
372
+ const c = [...t.memberClaims.values()].filter((x) => x.email === String(email).toLowerCase() && x.member_id.startsWith('person:') && !x.claimed_by && !taken.has(x.member_id)).sort((a, b) => a.member_id < b.member_id ? -1 : 1)[0]
373
+ t.accounts.set(id, { id, email, name, member_id: c ? c.member_id : `person:${id}`, created_at: at, seen_at: at })
374
+ if (c) { c.claimed_by = id; c.claimed_at = at }
375
+ return clone(t.accounts.get(id))
376
+ },
377
+ addMemberClaims ({ claims }) {
378
+ let added = 0
379
+ for (const c of claims) if (!t.memberClaims.has(c.member_id)) { t.memberClaims.set(c.member_id, { member_id: c.member_id, email: String(c.email).toLowerCase(), name: c.name ?? null, claimed_by: null, claimed_at: null }); added++ }
380
+ return { added }
381
+ },
382
+ // Seam's own keys (keys.js): a computer's or an agent's; `replace` revokes its earlier live keys.
383
+ addKey (r) {
384
+ if (r.agent_id) { const a = t.agents.get(r.agent_id); if (!a || a.account_id !== r.account_id || a.revoked_at) throw new StoreError('not_found', 'no such agent') }
385
+ if (r.replace) for (const x of t.keys.values()) if (x.account_id === r.account_id && !x.revoked_at && ((r.agent_id && x.agent_id === r.agent_id) || (r.public_key && x.public_key === r.public_key) || (r.grant_id && x.grant_id === r.grant_id))) x.revoked_at = r.created_at
386
+ const { replace, ...row } = r
387
+ t.keys.set(r.id, { agent_id: null, public_key: null, expires_at: null, client_id: null, grant_id: null, scopes: null, ...row, revoked_at: null })
388
+ return clone(t.keys.get(r.id))
389
+ },
390
+ revokeKey ({ id, at }) {
391
+ const x = t.keys.get(id)
392
+ if (!x) throw new StoreError('not_found', 'no such key')
393
+ if (!x.revoked_at) x.revoked_at = at
394
+ return clone(x)
395
+ },
396
+ createAgent ({ id, account_id, name, created_at, max = 100, claim }) {
397
+ const acct = t.accounts.get(account_id)
398
+ if (!acct) throw new StoreError('not_found', 'no such account')
399
+ if ([...t.agents.values()].filter((a) => a.account_id === account_id && !a.revoked_at).length >= max) throw new StoreError('too_many_agents', 'too many agents; revoke one first')
400
+ if (claim) {
401
+ const c = t.memberClaims.get(claim)
402
+ if (!c || c.email !== acct.email.toLowerCase() || c.claimed_by) throw new StoreError('not_claimable', 'that agent is not one of yours to re-link, or it was re-linked already')
403
+ id = claim.slice(6); c.claimed_by = account_id; c.claimed_at = created_at
404
+ }
405
+ t.agents.set(id, { id, account_id, name, created_at, revoked_at: null })
406
+ return clone(t.agents.get(id))
407
+ },
408
+ revokeAgent ({ id, account_id, at }) {
409
+ const a = t.agents.get(id)
410
+ if (!a || a.account_id !== account_id) throw new StoreError('not_found', 'no such agent')
411
+ if (!a.revoked_at) { a.revoked_at = at; for (const x of t.keys.values()) if (x.agent_id === id && !x.revoked_at) x.revoked_at = at }
412
+ return clone(a)
413
+ },
414
+ // OAuth (oauth.js): an app's client, its one-use codes, and ending a grant's tokens.
415
+ addOAuthClient (r) {
416
+ if (!t.accounts.has(r.account_id)) throw new StoreError('not_found', 'no such account')
417
+ t.oauthClients.set(r.id, { ...r, revoked_at: null })
418
+ return clone(t.oauthClients.get(r.id))
419
+ },
420
+ revokeOAuthClient ({ id, account_id, at }) {
421
+ const c = t.oauthClients.get(id)
422
+ if (!c || c.account_id !== account_id) throw new StoreError('not_found', 'no such app')
423
+ if (!c.revoked_at) { c.revoked_at = at; for (const x of t.keys.values()) if (x.client_id === id && !x.revoked_at) x.revoked_at = at }
424
+ return clone(c)
425
+ },
426
+ addOAuthCode (r) {
427
+ for (const [h, c] of t.oauthCodes) if (c.expires_at < r.created_at - 86400_000) t.oauthCodes.delete(h)
428
+ t.oauthCodes.set(r.code_hash, { ...r, used_at: null })
429
+ return clone(t.oauthCodes.get(r.code_hash))
430
+ },
431
+ takeOAuthCode ({ code_hash: h, at }) {
432
+ const c = t.oauthCodes.get(h)
433
+ if (!c) return null
434
+ const out = { ...clone(c), was_used: c.used_at != null }
435
+ if (!c.used_at) c.used_at = at
436
+ return out
437
+ },
438
+ revokeGrant ({ grant_id: g, at }) {
439
+ let n = 0
440
+ for (const x of t.keys.values()) if (x.grant_id === g && !x.revoked_at) { x.revoked_at = at; n++ }
441
+ return { revoked: n }
442
+ },
443
+ addDeviceLink (r) {
444
+ for (const [id, l] of t.deviceLinks) if (l.expires_at < r.created_at - 86400_000) t.deviceLinks.delete(id)
445
+ if ([...t.deviceLinks.values()].some((l) => l.user_code === r.user_code)) throw new StoreError('exists', 'this code is taken')
446
+ t.deviceLinks.set(r.id, { platform: null, ...r, state: 'pending', account_id: null })
447
+ return clone(t.deviceLinks.get(r.id))
448
+ },
449
+ setDeviceLink ({ id, from, state, account_id }) {
450
+ const l = t.deviceLinks.get(id)
451
+ if (!l || l.state !== from) throw new StoreError('moved', 'this sign-in link changed; start again')
452
+ l.state = state
453
+ if (account_id) l.account_id = account_id
454
+ return clone(l)
455
+ },
359
456
  // A quilt token (tokens.js): a row and its membership token:<id>, made and ended together.
360
457
  createToken (r) {
361
458
  quiltRow(r.quilt_id)
@@ -392,12 +489,31 @@ export function createMemoryQuiltStore ({ file = null } = {}) {
392
489
  return clone(r)
393
490
  },
394
491
  // What a line needs to take a merge (13): a number of approvals.
395
- setLineRule ({ quilt_id: q, line, approvals, checks = [] }) {
492
+ setLineRule ({ quilt_id: q, line, approvals, checks = [], runners = {}, targeting = {} }) {
396
493
  if (!t.lines.has(k(q, line))) throw new StoreError('not_found', `no line ${line}`)
397
494
  let m = t.lineRules.get(q); if (!m) { m = new Map(); t.lineRules.set(q, m) }
398
- m.set(line, { line, approvals, checks: [...checks] })
495
+ m.set(line, { line, approvals, checks: [...checks], runners: clone(runners), targeting: clone(targeting), since_seq: quiltRow(q).head_seq })
399
496
  return clone(m.get(line))
400
497
  },
498
+ // Check runs (13): added once per run_key (a repeat is the same run); returns the stored rows.
499
+ addCheckRuns ({ quilt_id: q, runs }) {
500
+ quiltRow(q)
501
+ let m = t.checkRuns.get(q); if (!m) { m = new Map(); t.checkRuns.set(q, m) }
502
+ return runs.map((r) => { if (!m.has(r.run_key)) m.set(r.run_key, { ...clone(r), quilt_id: q, affected: r.affected ?? null, url: r.url ?? null, verdict: r.verdict ?? null, by: r.by ?? null, advisory: !!r.advisory, finished_at: r.finished_at ?? null }); return clone(m.get(r.run_key)) })
503
+ },
504
+ // Asks a check's runners again (13): a policy log row the events job turns into check.requested.
505
+ requestCheckRun ({ quilt_id: q, line, square_id: sq, actor, at, payload }) {
506
+ if (!t.lines.has(k(q, line))) throw new StoreError('not_found', `no line ${line}`)
507
+ return { seq: appendLog(q, { kind: 'policy', line, square: sq || null, actor, at, payload: clone(payload) }).seq }
508
+ },
509
+ // A run's state and verdict; `expect_state` refuses when it moved (square_state).
510
+ setCheckRun ({ quilt_id: q, run_key: key, state, verdict, by, url, at, expect_state: expect }) {
511
+ const r = t.checkRuns.get(q)?.get(key)
512
+ if (!r) throw new StoreError('not_found', `no check run ${key}`)
513
+ if (expect && r.state !== expect) throw Object.assign(new StoreError('square_state', `the run is ${r.state}`), { state: r.state })
514
+ Object.assign(r, { state, ...(verdict !== undefined ? { verdict } : {}), ...(by !== undefined ? { by } : {}), ...(url !== undefined ? { url } : {}), ...(state === 'done' || state === 'cancelled' ? { finished_at: at } : {}) })
515
+ return clone(r)
516
+ },
401
517
  // A check's latest result on a square (13): one row per (square, check), replaced by each report.
402
518
  setSquareCheck ({ quilt_id: q, square_id: sq, name, status, head, by, at }) {
403
519
  const row = t.squares.get(sq)
@@ -436,6 +552,36 @@ export function createMemoryQuiltStore ({ file = null } = {}) {
436
552
  Object.assign(ln, { frozen: !!frozen, updated_at: at })
437
553
  return clone(ln)
438
554
  },
555
+ // The quilt's import record (14), in settings.import, with the lines it freezes (freeze: true,
556
+ // reason import_follow) or unfreezes (freeze: false) in the same transaction. Refuses with
557
+ // `conflict` when the record's state is not expect_state, or a line's tip is not expect_tips[line].
558
+ setImport ({ quilt_id: q, import: rec, freeze = null, lines = [], expect_state, expect_tips, actor, at }) {
559
+ const quilt = quiltRow(q)
560
+ const cur = quilt.settings?.import || null
561
+ if (expect_state !== undefined && (cur?.state || null) !== expect_state) throw new StoreError('conflict', `the import is ${cur?.state || 'not started'}`, { state: cur?.state || null })
562
+ for (const [name, tip] of Object.entries(expect_tips || {})) {
563
+ const ln = t.lines.get(k(q, name))
564
+ if ((ln?.tip || null) !== (tip || null)) throw new StoreError('conflict', `${name} moved`, { line: name, tip: ln?.tip || null })
565
+ }
566
+ if (freeze !== null) {
567
+ for (const name of lines) {
568
+ const ln = t.lines.get(k(q, name))
569
+ if (!ln || !!ln.frozen === !!freeze) continue
570
+ appendLog(q, { kind: 'policy', line: name, actor, at, payload: { op: freeze ? 'freeze' : 'unfreeze', reason: 'import_follow' } })
571
+ Object.assign(ln, { frozen: !!freeze, updated_at: at })
572
+ }
573
+ }
574
+ if (!cur || cur.state !== rec.state) appendLog(q, { kind: 'policy', actor, at, payload: { op: 'import', id: rec.id, state: rec.state } })
575
+ quilt.settings = { ...(quilt.settings || {}), import: clone(rec) }
576
+ return clone(rec)
577
+ },
578
+ // The quilt's export to a git remote (14), in settings.export; a policy row when `logged`.
579
+ setExport ({ quilt_id: q, export: rec, logged = false, actor, at }) {
580
+ const quilt = quiltRow(q)
581
+ if (logged) appendLog(q, { kind: 'policy', actor, at, payload: { op: 'export', target: rec.target, enabled: !!rec.enabled, state: rec.status?.state || null } })
582
+ quilt.settings = { ...(quilt.settings || {}), export: clone(rec) }
583
+ return clone(rec)
584
+ },
439
585
  // The merge window's state on a line (07): current window, maximum, red checks.
440
586
  setLineWindow ({ quilt_id: q, line, merge_window: w, merge_window_max: m, red_checks: red }) {
441
587
  const ln = t.lines.get(k(q, line))
@@ -566,11 +712,24 @@ export function createMemoryQuiltStore ({ file = null } = {}) {
566
712
  async resolutions (q) { return [...(t.resolutions.get(q)?.values() || [])].map(clone) },
567
713
  async signingKey (id) { const r = t.signingKeys.get(id); return r ? clone(r) : null },
568
714
  async tokens (q) { return [...t.tokens.values()].filter((r) => r.quilt_id === q).sort((a, b) => a.created_at - b.created_at).map(clone) },
715
+ async keyByHash (hash) { const r = [...t.keys.values()].find((x) => x.hash === hash); return r ? clone(r) : null },
716
+ async deviceKey (publicKey) { const r = [...t.keys.values()].filter((x) => x.public_key === publicKey && !x.revoked_at).sort((a, b) => b.created_at - a.created_at)[0]; return r ? clone(r) : null },
717
+ async agentClaims (email) { return { claims: [...t.memberClaims.values()].filter((c) => c.email === String(email).toLowerCase() && c.member_id.startsWith('agent:') && !c.claimed_by).sort((a, b) => a.member_id < b.member_id ? -1 : 1).map((c) => ({ member_id: c.member_id, name: c.name })) } },
718
+ async getAgent (id) { const r = t.agents.get(id); return r ? clone(r) : null },
719
+ async agentsOf (accountId) { return [...t.agents.values()].filter((a) => a.account_id === accountId).sort((a, b) => a.created_at - b.created_at).map(clone) },
720
+ async deviceLink (by) { const r = [...t.deviceLinks.values()].find((l) => (by.code_hash && l.code_hash === by.code_hash) || (by.user_code && l.user_code === by.user_code)); return r ? clone(r) : null },
721
+ async oauthClient (id) { const r = t.oauthClients.get(id); return r ? clone(r) : null },
722
+ async oauthClientsOf (accountId) { return [...t.oauthClients.values()].filter((c) => c.account_id === accountId).sort((a, b) => a.created_at - b.created_at).map(clone) },
723
+ async getAccount (id) { const r = t.accounts.get(id); return r ? clone(r) : null },
569
724
  async tokenByHash (hash) { const r = [...t.tokens.values()].find((x) => x.hash === hash); return r ? clone(r) : null },
570
725
  async signingKeys (member) { return [...t.signingKeys.values()].filter((r) => r.member === member).sort((a, b) => a.added_at - b.added_at).map(clone) },
571
726
  async signaturesOf (q, commits) { return commits.flatMap((c) => t.signatures.get(k(q, c)) || []).map(clone) },
572
727
  async reviews (q, sq) { return (t.reviews.get(q) || []).filter((r) => r.square_id === sq).map(clone) },
573
728
  async squareChecks (q, sq) { return t.squares.get(sq)?.quilt_id === q ? [...(t.squareChecks.get(sq)?.values() || [])].map(clone) : [] },
729
+ async checkRuns (q, { state, name, tier, line, square_id: sq, limit = 100 } = {}) {
730
+ const all = [...(t.checkRuns.get(q)?.values() || [])].filter((r) => (!state || r.state === state) && (!name || r.name === name) && (!tier || r.tier === tier) && (!line || r.line === line) && (!sq || r.square_id === sq))
731
+ return all.reverse().slice(0, limit).map(clone)
732
+ },
574
733
  async lineRules (q) { return [...(t.lineRules.get(q)?.values() || [])].map(clone) },
575
734
  async claims (q) { return [...(t.claims.get(q)?.values() || [])].map(clone) },
576
735
  async getTag (q, name) { return clone(t.tags.get(k(q, name))) },
@@ -677,6 +836,8 @@ export function createMemoryQuiltStore ({ file = null } = {}) {
677
836
  async gitShas (q, shas) { const out = {}; for (const sha of shas) { const id = t.gitShas.get(k(q, sha)); if (id) out[sha] = id } return out },
678
837
  async gitShaOf (q, id) { return t.gitOf.get(k(q, id)) || null },
679
838
  async getBlock (id) { return clone(t.blocks.get(id)) },
839
+ /** Every import still following git: [{ quilt_id, import }]. */
840
+ async importsFollowing () { const out = []; for (const row of t.quilts.values()) if (row.settings?.import?.state === 'following') out.push({ quilt_id: row.id, import: clone(row.settings.import) }); return out },
680
841
  close () { if (journal) { fs.closeSync(journal); journal = null } }
681
842
  })
682
843
  return store
@@ -22,7 +22,7 @@ export function createRpcQuiltStore (rpc) {
22
22
  createQuilt: 'quilt_create_quilt', setMember: 'quilt_set_member', sealPack: 'quilt_seal_pack', stageFiles: 'quilt_stage_files',
23
23
  openSquare: 'quilt_open_square', commit: 'quilt_commit', beginMerge: 'quilt_begin_merge', endMerge: 'quilt_end_merge', merge: 'quilt_merge',
24
24
  importCommit: 'quilt_import_commit', setSnapshot: 'quilt_set_snapshot', setSquare: 'quilt_set_square', setGitSha: 'quilt_set_git_sha', remember: 'quilt_remember',
25
- addEvents: 'quilt_add_events', setWebhook: 'quilt_set_webhook', takeLease: 'quilt_take_lease', openAndCommit: 'quilt_open_and_commit', giveLease: 'quilt_give_lease', addStubs: 'quilt_add_stubs', fillLinePaths: 'quilt_fill_line_paths', setConflicted: 'quilt_set_conflicted', setArchive: 'quilt_set_archive', setLineWindow: 'quilt_set_line_window', createLine: 'quilt_create_line', setLineFrozen: 'quilt_set_line_frozen', createTag: 'quilt_create_tag', setLive: 'quilt_set_live', createClaim: 'quilt_create_claim', deleteClaim: 'quilt_delete_claim', setResolution: 'quilt_set_resolution', deleteResolution: 'quilt_delete_resolution', addReview: 'quilt_add_review', setLineRule: 'quilt_set_line_rule', setSquareCheck: 'quilt_set_square_check', addSigningKey: 'quilt_add_signing_key', removeSigningKey: 'quilt_remove_signing_key', addSignature: 'quilt_add_signature', createToken: 'quilt_create_token', revokeToken: 'quilt_revoke_token'
25
+ addEvents: 'quilt_add_events', setWebhook: 'quilt_set_webhook', takeLease: 'quilt_take_lease', openAndCommit: 'quilt_open_and_commit', giveLease: 'quilt_give_lease', addStubs: 'quilt_add_stubs', fillLinePaths: 'quilt_fill_line_paths', setConflicted: 'quilt_set_conflicted', setArchive: 'quilt_set_archive', setLineWindow: 'quilt_set_line_window', createLine: 'quilt_create_line', setLineFrozen: 'quilt_set_line_frozen', createTag: 'quilt_create_tag', setLive: 'quilt_set_live', createClaim: 'quilt_create_claim', deleteClaim: 'quilt_delete_claim', setResolution: 'quilt_set_resolution', deleteResolution: 'quilt_delete_resolution', addReview: 'quilt_add_review', setLineRule: 'quilt_set_line_rule', setSquareCheck: 'quilt_set_square_check', addSigningKey: 'quilt_add_signing_key', removeSigningKey: 'quilt_remove_signing_key', addSignature: 'quilt_add_signature', createToken: 'quilt_create_token', revokeToken: 'quilt_revoke_token', setImport: 'quilt_set_import', setExport: 'quilt_set_export', addCheckRuns: 'quilt_add_check_runs', setCheckRun: 'quilt_set_check_run', requestCheckRun: 'quilt_request_check_run', accountFor: 'quilt_account_for', addKey: 'quilt_add_key', revokeKey: 'quilt_revoke_key', createAgent: 'quilt_create_agent', revokeAgent: 'quilt_revoke_agent', addDeviceLink: 'quilt_add_device_link', setDeviceLink: 'quilt_set_device_link', addMemberClaims: 'quilt_add_member_claims', addOAuthClient: 'quilt_add_oauth_client', revokeOAuthClient: 'quilt_revoke_oauth_client', addOAuthCode: 'quilt_add_oauth_code', takeOAuthCode: 'quilt_take_oauth_code', revokeGrant: 'quilt_revoke_grant'
26
26
  }
27
27
  const store = {}
28
28
  for (const [name, fn] of Object.entries(writes)) store[name] = (args) => call(fn, args)
@@ -40,12 +40,22 @@ export function createRpcQuiltStore (rpc) {
40
40
  claims: (q) => call('quilt_claims_of', { quilt_id: q }),
41
41
  tokens: (q) => call('quilt_tokens_of', { quilt_id: q }),
42
42
  tokenByHash: (hash) => call('quilt_token_by_hash', { hash }),
43
+ oauthClient: (id) => call('quilt_oauth_client', { id }),
44
+ oauthClientsOf: (accountId) => call('quilt_oauth_clients_of', { account_id: accountId }),
45
+ getAccount: (id) => call('quilt_get_account', { id }),
46
+ keyByHash: (hash) => call('quilt_key_by_hash', { hash }),
47
+ deviceKey: (publicKey) => call('quilt_device_key', { public_key: publicKey }),
48
+ getAgent: (id) => call('quilt_get_agent', { id }),
49
+ agentClaims: (email) => call('quilt_agent_claims', { email }),
50
+ agentsOf: (accountId) => call('quilt_agents_of', { account_id: accountId }),
51
+ deviceLink: (by) => call('quilt_device_link', by),
43
52
  signingKey: (id) => call('quilt_signing_key', { id }),
44
53
  signingKeys: (member) => call('quilt_signing_keys_of', { member }),
45
54
  signaturesOf: (q, commits) => call('quilt_signatures_of', { quilt_id: q, commits }),
46
55
  reviews: (q, sq) => call('quilt_reviews_of', { quilt_id: q, square_id: sq }),
47
56
  squareChecks: (q, sq) => call('quilt_square_checks_of', { quilt_id: q, square_id: sq }),
48
57
  lineRules: (q) => call('quilt_line_rules_of', { quilt_id: q }),
58
+ checkRuns: (q, { state, name, tier, line, square_id: sq, limit = 100 } = {}) => call('quilt_check_runs_of', { quilt_id: q, state, name, tier, line, square_id: sq, limit }),
49
59
  resolution: (q, key) => call('quilt_resolution', { quilt_id: q, key }),
50
60
  resolutions: (q) => call('quilt_resolutions_of', { quilt_id: q }),
51
61
  getSquare: (id) => call('quilt_get_square', { id }),
@@ -74,6 +84,7 @@ export function createRpcQuiltStore (rpc) {
74
84
  gitShas: async (q, shas) => (await call('quilt_git_shas', { quilt_id: q, shas })) || {},
75
85
  gitShaPrefix: (q, prefix) => call('quilt_git_sha_prefix', { quilt_id: q, prefix }),
76
86
  gitShaOf: (q, id) => call('quilt_git_sha_of', { quilt_id: q, commit_id: id }),
87
+ importsFollowing: () => call('quilt_imports_following', {}),
77
88
  close () {}
78
89
  })
79
90
  }