heyquilt 0.3.121 → 0.3.138

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 (129) hide show
  1. package/RELEASES.md +292 -17
  2. package/bin/quilt.js +72 -5
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -1
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +21 -10
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/seam-connect.js +103 -0
  15. package/src/api/routes/session-migrate.js +39 -0
  16. package/src/api/routes/sessions.js +73 -1
  17. package/src/api/server.js +46 -12
  18. package/src/api/supabase-store.js +53 -4
  19. package/src/autowork.js +182 -16
  20. package/src/connection.js +39 -9
  21. package/src/control.js +139 -13
  22. package/src/force-date.js +27 -0
  23. package/src/fsutil.js +13 -3
  24. package/src/gitstate.js +38 -2
  25. package/src/history.js +25 -1
  26. package/src/mcp.js +93 -7
  27. package/src/merge3.js +11 -3
  28. package/src/now-summary.js +113 -0
  29. package/src/pathrules.js +12 -0
  30. package/src/protocol.js +10 -2
  31. package/src/quilt-default.js +84 -0
  32. package/src/quilt-history.js +106 -0
  33. package/src/quilt-remote.js +78 -0
  34. package/src/relay-commit.js +5 -0
  35. package/src/relay-lease.js +6 -2
  36. package/src/relay-mcp.js +172 -8
  37. package/src/relay-outbound.js +1 -1
  38. package/src/relay-prune.js +4 -1
  39. package/src/release-words.js +2 -2
  40. package/src/runner.js +20 -7
  41. package/src/seam-access.js +97 -0
  42. package/src/seam-claims.js +53 -0
  43. package/src/seam-url.js +15 -0
  44. package/src/server.js +1767 -33
  45. package/src/session-follow.js +515 -0
  46. package/src/session-layout3.js +466 -0
  47. package/src/session-migrate.js +123 -0
  48. package/src/session-quilt.js +145 -0
  49. package/src/session-seam.js +284 -0
  50. package/src/session.js +899 -46
  51. package/src/source-live/bring-in.js +283 -0
  52. package/src/source-live/live-block.js +507 -35
  53. package/src/source-live/live-square.js +17 -6
  54. package/src/status.js +5 -0
  55. package/src/ui/agent-guide.js +1 -1
  56. package/src/ui/app.css +85 -20
  57. package/src/ui/app.js +4 -1
  58. package/src/ui/chat.js +9 -3
  59. package/src/ui/cloud-ai-model.js +13 -0
  60. package/src/ui/cloud-ai.js +10 -2
  61. package/src/ui/common.js +6 -2
  62. package/src/ui/fileview.js +20 -7
  63. package/src/ui/force-words.js +19 -0
  64. package/src/ui/git-rule.js +9 -0
  65. package/src/ui/home.js +0 -147
  66. package/src/ui/loom-model.js +15 -1
  67. package/src/ui/quiltview.js +213 -0
  68. package/src/ui/seam-model.js +251 -0
  69. package/src/ui/session.js +413 -31
  70. package/src/ui/voice-call.js +83 -14
  71. package/src/ui/voice-model.js +39 -0
  72. package/src/ui/voice.js +167 -17
  73. package/src/ui-server.js +80 -28
  74. package/src/update-check.js +25 -1
  75. package/src/voice.js +13 -6
  76. package/src/git.js +0 -147
  77. package/src/quilts/AGENTS.md +0 -1
  78. package/src/quilts/CLAUDE.md +0 -1
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/claims.js +0 -144
  87. package/src/quilts/cli.js +0 -449
  88. package/src/quilts/client.js +0 -128
  89. package/src/quilts/conflict-format.js +0 -190
  90. package/src/quilts/describe.js +0 -266
  91. package/src/quilts/dogfood.js +0 -82
  92. package/src/quilts/errors.js +0 -8
  93. package/src/quilts/events.js +0 -160
  94. package/src/quilts/export-git.js +0 -130
  95. package/src/quilts/export.js +0 -81
  96. package/src/quilts/fastmerge.js +0 -41
  97. package/src/quilts/hash.js +0 -44
  98. package/src/quilts/history.js +0 -266
  99. package/src/quilts/ids.js +0 -12
  100. package/src/quilts/import-git.js +0 -291
  101. package/src/quilts/import.js +0 -134
  102. package/src/quilts/index.js +0 -72
  103. package/src/quilts/limits.js +0 -67
  104. package/src/quilts/lines.js +0 -49
  105. package/src/quilts/live.js +0 -50
  106. package/src/quilts/merge-smart.js +0 -114
  107. package/src/quilts/merge.js +0 -486
  108. package/src/quilts/objects.js +0 -144
  109. package/src/quilts/pack.js +0 -118
  110. package/src/quilts/records.js +0 -211
  111. package/src/quilts/resolutions.js +0 -69
  112. package/src/quilts/reviews.js +0 -195
  113. package/src/quilts/routes.js +0 -126
  114. package/src/quilts/service.js +0 -923
  115. package/src/quilts/setup.js +0 -34
  116. package/src/quilts/signing.js +0 -149
  117. package/src/quilts/squares.js +0 -239
  118. package/src/quilts/store-memory.js +0 -683
  119. package/src/quilts/store-rpc.js +0 -79
  120. package/src/quilts/store-supabase.js +0 -13
  121. package/src/quilts/tags.js +0 -64
  122. package/src/quilts/textdiff.js +0 -96
  123. package/src/quilts/tokens.js +0 -66
  124. package/src/quilts/tools.js +0 -375
  125. package/src/quilts/tree.js +0 -351
  126. package/src/quilts/webhook.js +0 -116
  127. package/src/quilts/window.js +0 -119
  128. package/src/quilts/xdiff.js +0 -435
  129. package/src/ui/branches.js +0 -111
@@ -0,0 +1,103 @@
1
+ // "Connect Seam": a Quilt person links their own Seam account to Quilt, the way any app connects
2
+ // to Seam (Seam's OAuth 2, authorization code with PKCE). Quilt never shares accounts with Seam:
3
+ // it keeps the person's Seam token and calls Seam's HTTP API with it. Off unless Quilt is
4
+ // registered as a Seam app (seamClientId, env QUILT_SEAM_CLIENT_ID).
5
+ //
6
+ // The PKCE verifier is never stored: `state` carries who asked and until when, signed with
7
+ // Quilt's secret, and the verifier is an HMAC of the state, so any API machine can finish it.
8
+ import crypto from 'node:crypto'
9
+ import { HttpError, Raw } from '../http.js'
10
+
11
+ export const CONNECT_MS = 10 * 60_000
12
+ const REFRESH_EARLY_MS = 60_000
13
+
14
+ const b64 = (b) => Buffer.from(b).toString('base64url')
15
+ const mac = (secret, text) => crypto.createHmac('sha256', secret).update(text).digest('base64url')
16
+ const same = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
17
+ const page = (status, title, text) => new Raw(status, `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui;margin:4em auto;max-width:32em"><h1>${title}</h1><p>${text}</p></body>`, 'text/html; charset=utf-8')
18
+
19
+ export function seamConnectRoutes ({ store, person, now = Date.now, apiUrl, seamUrl, seamSiteUrl = 'https://seam.heyquilt.com', clientId, secret, fetch = globalThis.fetch, log = () => {} }) {
20
+ if (!clientId || !secret || !seamUrl || !store.seamLink) return []
21
+ const seam = String(seamUrl).replace(/\/+$/, '')
22
+ const site = String(seamSiteUrl).replace(/\/+$/, '')
23
+ const redirectUri = `${String(apiUrl).replace(/\/+$/, '')}/v1/seam/connect/callback`
24
+ const verifierOf = (state) => mac(secret, `seam-pkce\0${state}`)
25
+ const userOf = (p) => p.userId || p.id
26
+
27
+ function makeState (userId) {
28
+ const body = b64(JSON.stringify({ u: userId, e: now() + CONNECT_MS, n: crypto.randomBytes(12).toString('base64url') }))
29
+ return `${body}.${mac(secret, `seam-state\0${body}`)}`
30
+ }
31
+ function readState (state) {
32
+ const [body, sig] = String(state || '').split('.')
33
+ if (!body || !sig || !same(sig, mac(secret, `seam-state\0${body}`))) return null
34
+ try {
35
+ const s = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
36
+ return s && s.e > now() ? s : null
37
+ } catch { return null }
38
+ }
39
+
40
+ async function tokenCall (form) {
41
+ let res
42
+ try { res = await fetch(`${seam}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: new URLSearchParams({ client_id: clientId, ...form }).toString(), signal: AbortSignal.timeout(10_000) }) } catch { throw new HttpError(503, 'Seam could not be reached; try again') }
43
+ const data = await res.json().catch(() => null)
44
+ if (!res.ok || !data?.access_token) return { error: data?.error || `seam_${res.status}`, detail: data?.error_description || '' }
45
+ return { access: data.access_token, refresh: data.refresh_token, scope: data.scope || '', expiresAt: now() + Number(data.expires_in || 3600) * 1000 }
46
+ }
47
+
48
+ /** The person's Seam access token, refreshed when it is about to end; null when not connected. */
49
+ async function freshToken (userId) {
50
+ const link = await store.seamLink(userId)
51
+ if (!link) return null
52
+ if (link.expiresAt - REFRESH_EARLY_MS > now()) return link
53
+ const t = await tokenCall({ grant_type: 'refresh_token', refresh_token: link.refresh })
54
+ if (t.error) {
55
+ // Seam ended the grant (revoked on seam.heyquilt.com, or the token was reused): connect again.
56
+ if (t.error === 'invalid_grant' || t.error === 'invalid_client') { await store.deleteSeamLink(userId); return null }
57
+ throw new HttpError(503, 'Seam could not refresh your connection; try again')
58
+ }
59
+ return store.putSeamLink(userId, { ...t, connectedAt: link.connectedAt })
60
+ }
61
+
62
+ return [
63
+ // { scope? } → { url }: the page on Seam's site where the person allows Quilt.
64
+ ['POST', /^\/v1\/seam\/connect$/, async (req, body) => {
65
+ const p = await person(req)
66
+ const scope = String(body?.scope || 'quilts:write').trim()
67
+ const state = makeState(userOf(p))
68
+ const challenge = crypto.createHash('sha256').update(verifierOf(state)).digest('base64url')
69
+ const q = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope, state, code_challenge: challenge, code_challenge_method: 'S256' })
70
+ return { url: `${site}/oauth/authorize?${q}` }
71
+ }],
72
+ // Seam sends the browser back here with a code (or error=access_denied).
73
+ ['GET', /^\/v1\/seam\/connect\/callback$/, async (req) => {
74
+ const q = new URL(req.url, 'http://x').searchParams
75
+ const s = readState(q.get('state'))
76
+ if (!s) return page(400, 'Seam was not connected', 'This link expired or was not made by Quilt. Start again from Quilt.')
77
+ if (q.get('error')) return page(200, 'Seam was not connected', 'You chose not to connect Seam. You can connect it from Quilt any time.')
78
+ const t = await tokenCall({ grant_type: 'authorization_code', code: q.get('code') || '', redirect_uri: redirectUri, code_verifier: verifierOf(q.get('state')) })
79
+ if (t.error) { log(`seam connect: ${t.error} ${t.detail}`); return page(400, 'Seam was not connected', 'Seam did not accept this sign-in. Start again from Quilt.') }
80
+ await store.putSeamLink(s.u, { ...t, connectedAt: now() })
81
+ return page(200, 'Seam is connected', 'Quilt can now use Seam as you. You can close this tab.')
82
+ }],
83
+ ['GET', /^\/v1\/seam\/connection$/, async (req) => {
84
+ const link = await store.seamLink(userOf(await person(req)))
85
+ return link ? { connected: true, scope: link.scope, connectedAt: link.connectedAt } : { connected: false }
86
+ }],
87
+ // The person's own Seam token, for their app to call Seam's API with.
88
+ ['GET', /^\/v1\/seam\/token$/, async (req) => {
89
+ const link = await freshToken(userOf(await person(req)))
90
+ if (!link) throw new HttpError(404, 'Seam is not connected: connect it first')
91
+ return { access_token: link.access, token_type: 'Bearer', expires_at: link.expiresAt, scope: link.scope, seam_url: seam }
92
+ }],
93
+ ['DELETE', /^\/v1\/seam\/connection$/, async (req) => {
94
+ const userId = userOf(await person(req))
95
+ const link = await store.seamLink(userId)
96
+ if (link) {
97
+ await fetch(`${seam}/oauth/revoke`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: link.refresh }).toString(), signal: AbortSignal.timeout(10_000) }).catch(() => {})
98
+ await store.deleteSeamLink(userId)
99
+ }
100
+ return { connected: false }
101
+ }]
102
+ ]
103
+ }
@@ -0,0 +1,39 @@
1
+ // Making a session a quilt (Seam's plans/quilt-source-control/16, "How a session becomes a quilt"):
2
+ // POST /v1/sessions/:room/migrate { dry_run, token, step }. The session's owner, signed in, asks here
3
+ // (step 'make' is "Make this a quilt": made and moved into its squares in one go; 'switch' moves the session's files into its squares, layout 3; 'unswitch' goes back;
4
+ // 'shadow' links a session with a git remote to the quilt its import fills, 16 step 1);
5
+ // the relay machine holding the room does the work (POST /quilt/migrate, with the relay secret),
6
+ // so this is the same operation as the MCP tool and `quilt session migrate`. Seam is called as the
7
+ // owner: with `token` (their Seam sign-in) when given, else the sign-in they used here. The relay
8
+ // refuses while Seam is off for it (QUILT_SOURCE_URL), so nothing changes until that flag is set.
9
+ import { HttpError } from '../http.js'
10
+
11
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
12
+ const NO_SESSION = 'no such session'
13
+ const MIGRATE_TIMEOUT_MS = 5 * 60 * 1000 // a large session's patch 1 and squares take a while
14
+
15
+ export function sessionMigrateRoutes ({ store, person, bearer, relaySecret, cluster }) {
16
+ return [
17
+ ['POST', /^\/v1\/sessions\/([^/]+)\/migrate$/, async (req, body, [room]) => {
18
+ const account = `person:${(await person(req)).userId}`
19
+ if (!ROOM.test(room)) throw new HttpError(404, NO_SESSION)
20
+ const session = await store.sessionByRoom(room).catch(() => null)
21
+ if (!session) throw new HttpError(404, NO_SESSION)
22
+ if (session.ownerAccount !== account) throw new HttpError(403, 'Only the session owner can make it a quilt.')
23
+ if (!relaySecret) throw new HttpError(503, 'the relay is not set up on this server')
24
+ const dry = body && body.dry_run === true
25
+ const token = body && typeof body.token === 'string' && body.token ? body.token : bearer(req)
26
+ const where = await store.relayRoom(room).catch(() => null)
27
+ const step = body && (body.step === 'make' || body.step === 'switch' || body.step === 'unswitch' || body.step === 'shadow') ? body.step : null
28
+ // step 'shadow' (16 step 1): `quilt` names the quilt an import of the session's git remote fills; null unlinks.
29
+ const quilt = step === 'shadow' && typeof body.quilt === 'string' && body.quilt ? body.quilt.slice(0, 200) : null
30
+ const ask = { room, account, ...(dry ? { dry_run: true } : { token }), ...(step && !dry ? { step } : {}), ...(step === 'shadow' && !dry ? { quilt } : {}), ...(step === 'unswitch' && !dry && Array.isArray(body.carried) ? { carried: body.carried.filter((x) => typeof x === 'string').slice(0, 200) } : {}) }
31
+ try {
32
+ return await cluster.callRelay((where && where.machineId) || '', 'POST', '/quilt/migrate', ask, { timeoutMs: MIGRATE_TIMEOUT_MS })
33
+ } catch (err) {
34
+ const said = String(err.message).replace(/^relay .*? answered \d+: /, '')
35
+ throw new HttpError(err.status && err.status < 500 ? err.status : 502, said)
36
+ }
37
+ }]
38
+ ]
39
+ }
@@ -3,7 +3,7 @@
3
3
  // who were there at the same time as you.
4
4
  import { HttpError, Raw } from '../http.js'
5
5
  import { auditTrail, auditCsv, MAX_ACTIONS } from '../audit.js'
6
- import { summarize, collaborators, myVisits, isTimeZone, weekStart, monthStart, MAX_SESSIONS } from '../activity.js'
6
+ import { summarize, collaborators, myVisits, isTimeZone, weekStart, monthStart, MAX_SESSIONS, DAY_MS } from '../activity.js'
7
7
  import { cleanSessionName, BAD_SESSION_NAME } from '../../session-name.js'
8
8
 
9
9
  const ROOM = /^[A-Za-z0-9_-]{1,64}$/
@@ -90,3 +90,75 @@ export function sessionRoutes ({ store, now, person }) {
90
90
  ['GET', /^\/v1\/me\/collaborators$/, async (req) => ({ collaborators: await collaboratorsOf(store, await me(req), now()) })]
91
91
  ]
92
92
  }
93
+
94
+ export const NOW_CACHE_MS = 10 * 1000
95
+ export const NOW_TIMEOUT_MS = 5 * 1000
96
+ const NOW_ROOMS = 40
97
+
98
+ /**
99
+ * Asks the relay machines holding an account's recent sessions for its summary of now, and merges their
100
+ * answers: (account, t) → { sessions, at, live, partial? }. The dashboard's route and the email notices
101
+ * (notify.js) both use it.
102
+ */
103
+ export function nowAsker ({ store, cluster, relayOn = false, log = () => {} }) {
104
+ return async (account, t) => {
105
+ if (!relayOn) return { sessions: [], at: t, live: false }
106
+ // The sessions that can be live: the latest ones, and every one active in the last day.
107
+ const sessions = await store.accountSessions(account, { since: t - DAY_MS, limit: 20 })
108
+ const rooms = sessions.map((s) => s.room).slice(0, NOW_ROOMS)
109
+ const byMachine = new Map()
110
+ await Promise.all(rooms.map(async (room) => {
111
+ const placed = await store.relayRoom(room).catch(() => null)
112
+ const id = (placed && placed.machineId) || ''
113
+ if (!byMachine.has(id)) byMachine.set(id, [])
114
+ byMachine.get(id).push(room)
115
+ }))
116
+ let partial = false
117
+ const answers = await Promise.all([...byMachine].map(([id, list]) =>
118
+ cluster.callRelay(id, 'POST', '/now', { account, rooms: list }, { timeoutMs: NOW_TIMEOUT_MS })
119
+ .catch((err) => { partial = true; log(`now from relay ${id || ''}: ${err.message}`); return null })))
120
+ return { sessions: answers.flatMap((a) => (a && Array.isArray(a.sessions) ? a.sessions : [])), at: t, live: true, ...(partial ? { partial: true } : {}) }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The website dashboard's summary of now (plans/dashboard ticket 9): for each of my sessions a relay
126
+ * holds, who is in it, what each AI is doing, the board in brief, held files and what needs me
127
+ * (src/now-summary.js on the relay). The relay machines holding my recent sessions are asked, at most
128
+ * once every NOW_CACHE_MS per person (the page asks every 15 to 30 seconds), and their answers merged.
129
+ * `relayOn` false (no relay or no relay secret): nothing to ask, so nothing is live.
130
+ */
131
+ export function nowRoutes ({ store, now, person, cluster, relayOn = false, log = () => {} }) {
132
+ const cache = new Map() // account -> { at, out }
133
+ const ask = nowAsker({ store, cluster, relayOn, log })
134
+ return [
135
+ ['GET', /^\/v1\/me\/now$/, async (req) => {
136
+ const account = `person:${(await person(req)).userId}`
137
+ const t = now()
138
+ const hit = cache.get(account)
139
+ if (hit && t - hit.at < NOW_CACHE_MS) return hit.out
140
+ const out = await ask(account, t)
141
+ cache.set(account, { at: t, out })
142
+ if (cache.size > 5000) for (const [k, v] of cache) if (t - v.at >= NOW_CACHE_MS) cache.delete(k)
143
+ return out
144
+ }],
145
+
146
+ // One session I was in, in more detail (its page's Activity tab): { session } or { session: null } when
147
+ // nobody is in it now. 404 for a session I was never in, whether or not it exists.
148
+ ['GET', /^\/v1\/me\/now\/([^/]+)$/, async (req, body, [room]) => {
149
+ const account = `person:${(await person(req)).userId}`
150
+ if (!ROOM.test(room)) throw new HttpError(404, NO_SESSION)
151
+ const visits = await store.visitsInRooms([room])
152
+ if (!visits.some((v) => v.account === account)) throw new HttpError(404, NO_SESSION)
153
+ if (!relayOn) return { session: null, live: false }
154
+ const placed = await store.relayRoom(room).catch(() => null)
155
+ try {
156
+ const a = await cluster.callRelay((placed && placed.machineId) || '', 'POST', '/now', { account, rooms: [room], detail: true }, { timeoutMs: NOW_TIMEOUT_MS })
157
+ return { session: (a && Array.isArray(a.sessions) && a.sessions[0]) || null, live: true }
158
+ } catch (err) {
159
+ log(`now for ${room}: ${err.message}`)
160
+ return { session: null, live: true, partial: true }
161
+ }
162
+ }]
163
+ ]
164
+ }
package/src/api/server.js CHANGED
@@ -28,7 +28,10 @@ import crypto from 'node:crypto'
28
28
  import { githubRoutes } from './routes/github.js'
29
29
  import { githubApp, ensureWebhookSecret, redeliverFailed } from './github-app.js'
30
30
  import { releaseSource } from './releases.js'
31
- import { sessionRoutes } from './routes/sessions.js'
31
+ import { sessionRoutes, nowRoutes, nowAsker, overviewOf } from './routes/sessions.js'
32
+ import { notifier, NOTIFY_EVERY_MS } from './notify.js'
33
+ import { browserRoutes } from './routes/browsers.js'
34
+ import { sessionMigrateRoutes } from './routes/session-migrate.js'
32
35
  import { accessTypeRoutes } from './routes/access-types.js'
33
36
  import { grantRoutes } from './routes/grants.js'
34
37
  import { sessionInviteRoutes } from './routes/session-invites.js'
@@ -37,6 +40,7 @@ import { workspaceFileRoutes } from './routes/workspace-files.js'
37
40
  import { workspaceAgentRoutes } from './routes/workspace-agents.js'
38
41
  import { workspaceInviteRoutes } from './routes/workspace-invites.js'
39
42
  import { HOSTED_RELAY } from '../settings.js'
43
+ import { seamConnectRoutes } from './routes/seam-connect.js'
40
44
  import { roomAccessFrom } from './access.js'
41
45
  import { joiningRoom, sessionArg } from '../mcp-room.js'
42
46
  import { issueRoutes } from './routes/issues.js'
@@ -47,7 +51,6 @@ import { secretRoutes } from './routes/secrets.js'
47
51
  import { voiceRoutes } from './routes/voice.js'
48
52
  import { mailRoutes } from './routes/mail.js'
49
53
  import { agentWakeRoutes } from './routes/agent-wake.js'
50
- import { quiltRoutes } from '../quilts/routes.js'
51
54
  import { workspaceReach } from './workspace-reach.js'
52
55
  import { leader, LEADER_ONLY } from './leader.js'
53
56
  import { autoscale } from './autoscale.js'
@@ -73,7 +76,7 @@ const MCP_TIMEOUT_MS = 30 * 1000
73
76
  // a change to its grant reaches it as soon as it reaches a connected app.
74
77
  const PASS_REUSE_MARGIN_MS = 5 * 60 * 1000
75
78
 
76
- export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, siteUrl, apiUrl = 'https://api.heyquilt.com', mailer = { send: async () => { throw new Error('no mailer configured') } }, now = Date.now, log = () => {}, startLimit = 10, inviteLimit = 10, inviteSendLimit = 20, tokenLimit = 30, joinLimit = 20, trustProxy = false, maxStartKeys = 10_000, passKey = '', passLimit = 60, relayUrl = HOSTED_RELAY, mcpLimit = 600, reportKey = '', reportLimit = 10, slowMs = 2000, pruneEveryMs = 60 * 60 * 1000, keepEventsMs = 30 * 24 * 60 * 60 * 1000, keepIssuesMs = 90 * 24 * 60 * 60 * 1000, pruneStartMs = 10_000, relaySecret = '', workspaces = false, fileStore = null, maxFileBytes = 500 * 1024 * 1024, workspaceQuotaBytes = 5 * 1024 * 1024 * 1024, maxWorkspaceFiles = 2000, webhookFetch = globalThis.fetch, webhookLookup, allowLocalWebhooks = false, github = null, githubFetch = globalThis.fetch, fly = null, aiVmWaitMs = AI_VM_WAIT_MS, aiVmSpareSuspendMs, relayFetch = null, relayFly = null, heavy = null, relayTargets = null, clusterTiming = {}, vaultSecret = '', vaultUrl = 'https://vault.heyquilt.com', allowLocalUpstreams = false, secretWaitMs, voice = {}, voiceFetch = globalThis.fetch, mail = {}, mailFetch = globalThis.fetch, machineId = '', leaderTiming = {}, autoscaleTiming = {}, exit = () => process.exit(0), apiFly = null, maxMachines, quilts = null }) {
79
+ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, siteUrl, apiUrl = 'https://api.heyquilt.com', mailer = { send: async () => { throw new Error('no mailer configured') } }, now = Date.now, log = () => {}, startLimit = 10, inviteLimit = 10, inviteSendLimit = 20, tokenLimit = 30, joinLimit = 20, trustProxy = false, maxStartKeys = 10_000, passKey = '', passLimit = 60, relayUrl = HOSTED_RELAY, mcpLimit = 600, reportKey = '', reportLimit = 10, slowMs = 2000, pruneEveryMs = 60 * 60 * 1000, keepEventsMs = 30 * 24 * 60 * 60 * 1000, keepIssuesMs = 90 * 24 * 60 * 60 * 1000, pruneStartMs = 10_000, relaySecret = '', workspaces = false, quiltSourceDefault = false, fileStore = null, maxFileBytes = 500 * 1024 * 1024, workspaceQuotaBytes = 5 * 1024 * 1024 * 1024, maxWorkspaceFiles = 2000, webhookFetch = globalThis.fetch, webhookLookup, allowLocalWebhooks = false, github = null, githubFetch = globalThis.fetch, fly = null, aiVmWaitMs = AI_VM_WAIT_MS, aiVmSpareSuspendMs, relayFetch = null, relayFly = null, heavy = null, relayTargets = null, clusterTiming = {}, vaultSecret = '', vaultUrl = 'https://vault.heyquilt.com', allowLocalUpstreams = false, secretWaitMs, voice = {}, voiceFetch = globalThis.fetch, mail = {}, mailFetch = globalThis.fetch, machineId = '', leaderTiming = {}, autoscaleTiming = {}, exit = () => process.exit(0), apiFly = null, maxMachines, seamUrl = 'https://api.seam.heyquilt.com', seamFetch = globalThis.fetch, seamClientId = '', seamSiteUrl = 'https://seam.heyquilt.com', seamConnectSecret = '', notifyEveryMs = NOTIFY_EVERY_MS }) {
77
80
  // PASS_SIGNING_KEY. A bad one should stop the API at start, not fail every pass later.
78
81
  if (passKey) passPublicKey(passKey)
79
82
  const site = String(siteUrl || '').replace(/\/+$/, '')
@@ -222,7 +225,8 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
222
225
  ['GET', /^\/healthz$/, async () => ({ ok: true })],
223
226
 
224
227
  // Which optional parts this API has on, for the app and the MCPs to decide what to show. Public.
225
- ['GET', /^\/v1\/features$/, async () => ({ workspaces: !!workspaces, secrets: !!vaultSecret })],
228
+ // quilt_source_default (QUILT_SOURCE_DEFAULT, Seam's plans/quilt-source-control/16 flags order 8): apps make the sessions they start quilts from the start.
229
+ ['GET', /^\/v1\/features$/, async () => ({ workspaces: !!workspaces, secrets: !!vaultSecret, quilt_source_default: !!quiltSourceDefault })],
226
230
 
227
231
  ['POST', /^\/v1\/device\/start$/, async (req, body) => {
228
232
  limitStarts(req)
@@ -388,13 +392,16 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
388
392
  // A person's AI on its own cloud machine (routes/ai-vm.js): off (503) without Fly set up. Only the leader runs it.
389
393
  const aiVm = aiVmRoutes({ ...ctx, relayUrl, fly, waitMs: aiVmWaitMs, spareSuspendMs: aiVmSpareSuspendMs, leads: lead.leads })
390
394
  routes.push(...aiVm.routes)
395
+ // Email notices people turned on in the website's Settings › Notifications (notify.js): the leader sends them.
396
+ const notices = notifier({ store, mailer, ask: nowAsker({ store, cluster, relayOn: !!(relay && relaySecret), log }), overview: (account, t) => overviewOf(store, account, 'UTC', t), now, log, site, leads: lead.leads, everyMs: notifyEveryMs })
397
+ if (notifyEveryMs > 0) notices.start()
391
398
  // A team's git rule or who is on a team changed: the relay asks its sessions' rules again now (relay /github/policy).
392
399
  // With a room (a session put in or taken out of a workspace), only that session asks.
393
400
  const policyChanged = (room = '') => {
394
401
  if (!relay || !relaySecret) return
395
402
  cluster.tellRelays('/github/policy', room ? { room } : {}).catch((err) => log(`git rules to relay: ${err.message}`))
396
403
  }
397
- routes.push(...orgRoutes(ctx), ...memberRoutes(ctx), ...teamRoutes({ ...ctx, policyChanged }), ...inviteRoutes(ctx), ...agentRoutes({ ...ctx, passKey }), ...agentInviteRoutes({ ...ctx, workspaces }), ...joinRoutes(ctx), ...relayRoutes(ctx), ...githubRoutes({ ...ctx, github, githubFetch, relayHttp: relay, relayFetch: githubFetch, tellRelays: (path, body) => cluster.tellRelays(path, body), hookSecret, workspaces }), ...relayClusterRoutes({ ...ctx, cluster }), ...sessionRoutes(ctx), ...accessTypeRoutes(ctx), ...grantRoutes({ ...ctx, workspaces }), ...sessionInviteRoutes({ ...ctx, workspaces }), ...issueRoutes(ctx))
404
+ routes.push(...orgRoutes(ctx), ...memberRoutes(ctx), ...teamRoutes({ ...ctx, policyChanged }), ...inviteRoutes(ctx), ...agentRoutes({ ...ctx, passKey }), ...agentInviteRoutes({ ...ctx, workspaces }), ...joinRoutes(ctx), ...relayRoutes(ctx), ...githubRoutes({ ...ctx, github, githubFetch, relayHttp: relay, relayFetch: githubFetch, tellRelays: (path, body) => cluster.tellRelays(path, body), hookSecret, workspaces }), ...relayClusterRoutes({ ...ctx, cluster }), ...sessionRoutes(ctx), ...nowRoutes({ ...ctx, cluster, relayOn: !!(relay && relaySecret) }), ...browserRoutes(ctx), ...sessionMigrateRoutes({ ...ctx, cluster }), ...accessTypeRoutes(ctx), ...grantRoutes({ ...ctx, workspaces }), ...sessionInviteRoutes({ ...ctx, workspaces }), ...issueRoutes(ctx))
398
405
  // Always routed: with the flag off each answers a plain 404 of its own, so the app's
399
406
  // check at every launch isn't filed as a missing route.
400
407
  // Only these take a hosted agent's pass (workspaceReach), so they alone get the pass key.
@@ -416,16 +423,19 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
416
423
  routes.push(...mailRoutes({ ...ctx, passKey, mail, mailFetch, makeLimiter, tellRoom }))
417
424
  // How Quilt wakes each agent (routes/agent-wake.js): step 2 of joining.
418
425
  routes.push(...agentWakeRoutes({ ...ctx, passKey, mail, mailFetch, makeLimiter }))
419
- // Quilt source control (src/quilts/, plans/quilt-source-control/): off unless startApi gets `quilts`.
420
- // Seam takes who is calling from us, as a quilt member: { member_id, kind, name, tool?, on_behalf_of? }.
421
- const quiltCaller = quilts ? workspaceReach({ ...ctx, passKey }) : null
422
- const identifyQuiltMember = async (req) => {
423
- const c = await quiltCaller.caller(req)
426
+ // Seam (src/quilts/, Seam's plans/quilt-source-control/) is its own service at seamUrl. This API is its
427
+ // identity service for Quilt accounts: GET /v1/seam/me says who is calling, as a quilt member
428
+ // { member_id, kind, name, tool?, on_behalf_of? }, for a person's sign-in, an agent key or a pass.
429
+ const seamCaller = workspaceReach({ ...ctx, passKey })
430
+ const identifySeamMember = async (req) => {
431
+ const c = await seamCaller.caller(req)
424
432
  if (c.agent) return { member_id: c.account, kind: 'agent', name: c.agent.name || 'agent', ...(c.agent.provider ? { tool: String(c.agent.provider).toLowerCase() } : {}), ...(c.agent.ownerUserId ? { on_behalf_of: `person:${c.agent.ownerUserId}` } : {}) }
425
433
  const p = c.userId && store.profile ? await store.profile(c.userId).catch(() => null) : null
426
434
  return { member_id: c.account, kind: 'person', name: (p && p.name) || 'person' }
427
435
  }
428
- routes.push(...quiltRoutes({ quilts, identify: identifyQuiltMember }))
436
+ routes.push(['GET', /^\/v1\/seam\/me$/, (req) => identifySeamMember(req)])
437
+ // "Connect Seam": a person links their own Seam account (Seam's OAuth); off without seamClientId.
438
+ routes.push(...seamConnectRoutes({ store, person, now, apiUrl: api, seamUrl, seamSiteUrl, clientId: seamClientId, secret: seamConnectSecret || passKey, fetch: seamFetch, log }))
429
439
 
430
440
  async function openLink (code) {
431
441
  const userCode = normalizeUserCode(code)
@@ -488,6 +498,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
488
498
  try {
489
499
  if (pathname.startsWith('/v1/file-data/') && files instanceof DiskStore) return await serveFileData(req, res, send, pathname.slice('/v1/file-data/'.length))
490
500
  if (pathname === '/mcp') return await proxyMcp(req, res, send)
501
+ if (SEAM_PATH.test(pathname)) return await forwardSeam(req, res)
491
502
  if (req.method === 'GET' && (pathname === '/v1/releases/latest' || pathname.startsWith('/v1/download/'))) return await serveRelease(req, res, send, pathname)
492
503
  const url = new URL(req.url, 'http://x')
493
504
  const route = routes.find(([m, re]) => m === req.method && re.test(url.pathname))
@@ -514,6 +525,25 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
514
525
  }
515
526
  })
516
527
 
528
+ // Seam's routes moved to Seam's own server (Seam's docs/seam-service.md): old seam commands and sites
529
+ // still call them here, so they go on to seamUrl as sent, and Seam's answer comes straight back.
530
+ async function forwardSeam (req, res) {
531
+ if (!seamUrl) throw new HttpError(404, 'not found')
532
+ const headers = {}
533
+ for (const h of ['authorization', 'content-type', 'accept']) if (req.headers[h]) headers[h] = req.headers[h]
534
+ const hasBody = !['GET', 'HEAD', 'DELETE'].includes(req.method)
535
+ let up
536
+ try {
537
+ up = await seamFetch(seamUrl.replace(/\/+$/, '') + req.url, { method: req.method, headers, ...(hasBody ? { body: await readRaw(req, SEAM_MAX_BODY), duplex: 'half' } : {}), redirect: 'manual' })
538
+ } catch (err) {
539
+ if (err instanceof HttpError) throw err
540
+ throw new HttpError(502, 'Seam did not answer; try again')
541
+ }
542
+ const body = Buffer.from(await up.arrayBuffer())
543
+ res.writeHead(up.status, { 'content-type': up.headers.get('content-type') || 'application/json', 'cache-control': 'no-store', ...cors(req) })
544
+ res.end(body)
545
+ }
546
+
517
547
  // Downloads and the update check (releases.js): public, from the private repository's latest
518
548
  // release, read as Quilt's GitHub App. A download is a redirect to GitHub's own short-lived link.
519
549
  const releases = releaseSource({ github, fetch: githubFetch, now, apiUrl: api })
@@ -674,13 +704,17 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
674
704
 
675
705
  return new Promise((resolve) => server.listen(port, host, () => {
676
706
  const p = server.address().port
677
- resolve({ port: p, url: `http://${host}:${p}`, cluster, lead, scaler, grow, fillSpares: aiVm.fillSpares, close: () => { clearInterval(prune); clearTimeout(firstPrune); aiVm.close(); lead.close(); scaler.close(); grow.close(); return new Promise((r) => server.close(r)) }, startKeys: () => limitStarts.size(), sweepFiles: (at) => wsFiles.sweep(at), flushWebhooks, githubReady })
707
+ resolve({ port: p, url: `http://${host}:${p}`, cluster, lead, scaler, grow, fillSpares: aiVm.fillSpares, notices, close: () => { clearInterval(prune); clearTimeout(firstPrune); aiVm.close(); notices.stop(); lead.close(); scaler.close(); grow.close(); return new Promise((r) => server.close(r)) }, startKeys: () => limitStarts.size(), sweepFiles: (at) => wsFiles.sweep(at), flushWebhooks, githubReady })
678
708
  }))
679
709
  }
680
710
 
681
711
  // Which room a hosted agent's MCP request is about: shared with the relay (src/mcp-room.js).
682
712
  export { joiningRoom, sessionArg } from '../mcp-room.js'
683
713
 
714
+ // Seam's routes, answered by Seam's own server now (forwardSeam). Its biggest body is a pack.
715
+ const SEAM_PATH = /^\/v1\/(quilts|imports|search|signing-keys)(\/|$)/
716
+ const SEAM_MAX_BODY = 96 * 1024 * 1024
717
+
684
718
  function decodePart (s) {
685
719
  try { return decodeURIComponent(s) } catch { throw new HttpError(400, 'bad path') }
686
720
  }
@@ -69,6 +69,14 @@ export const rowFrom = (row) => row && Object.fromEntries(Object.entries(toCamel
69
69
  export function createSupabaseStore ({ url, serviceKey, client }) {
70
70
  const db = client || createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } })
71
71
  const one = async (q) => { const { data, error } = await q; if (error) throw error; return data }
72
+ // A lease call (relay_leases), or the git_sync_leases one while the relay_leases migration is not applied (PGRST202: no such function).
73
+ const leaseRpc = async (what, key, args, oldArgs) => {
74
+ const { data, error } = await db.rpc(`${what}_relay_lease`, args)
75
+ if (!error) return data
76
+ const at = String(key).indexOf(' ')
77
+ if (error.code !== 'PGRST202' || at < 1) throw error
78
+ return one(db.rpc(`${what}_git_sync_lease`, oldArgs(key.slice(0, at), key.slice(at + 1))))
79
+ }
72
80
  const memberOf = async (orgId, userId) => rowFrom(await one(db.from('org_members').select(MEMBER).eq('org_id', orgId).eq('user_id', userId).maybeSingle()))
73
81
  // Every row of a query, a page at a time. `build` makes a fresh query (with a stable order) per page.
74
82
  const pages = async (build) => {
@@ -343,10 +351,11 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
343
351
  await one(db.from('org_github').upsert(row, { onConflict: 'org_id' }))
344
352
  },
345
353
  // The API's own settings (app_settings): a value it keeps across starts, such as the webhook secret it set on GitHub.
346
- // One live session per repository and branch (git_sync_leases, through its locking functions).
347
- async takeLease ({ repo, branch, room, holder = '', ttlMs }) { return await one(db.rpc('take_git_sync_lease', { p_repo: repo, p_branch: branch, p_room: room, p_holder: holder, p_ttl_ms: ttlMs })) },
348
- async heartbeatLease ({ repo, branch, room, generation, ttlMs }) { return await one(db.rpc('heartbeat_git_sync_lease', { p_repo: repo, p_branch: branch, p_room: room, p_generation: generation, p_ttl_ms: ttlMs })) },
349
- async releaseLease ({ repo, branch, room, generation }) { return await one(db.rpc('release_git_sync_lease', { p_repo: repo, p_branch: branch, p_room: room, p_generation: generation })) },
354
+ // Relay leases (relay_leases, through its locking functions; 16 R2). `key`: the relay's name for what it holds.
355
+ // Before the migration that makes relay_leases is applied, a git branch's key ("<repo> <branch>") goes to the old functions.
356
+ async takeLease ({ key, room, holder = '', ttlMs }) { return await leaseRpc('take', key, { p_key: key, p_room: room, p_holder: holder, p_ttl_ms: ttlMs }, (repo, branch) => ({ p_repo: repo, p_branch: branch, p_room: room, p_holder: holder, p_ttl_ms: ttlMs })) },
357
+ async heartbeatLease ({ key, room, generation, ttlMs }) { return await leaseRpc('heartbeat', key, { p_key: key, p_room: room, p_generation: generation, p_ttl_ms: ttlMs }, (repo, branch) => ({ p_repo: repo, p_branch: branch, p_room: room, p_generation: generation, p_ttl_ms: ttlMs })) },
358
+ async releaseLease ({ key, room, generation }) { return await leaseRpc('release', key, { p_key: key, p_room: room, p_generation: generation }, (repo, branch) => ({ p_repo: repo, p_branch: branch, p_room: room, p_generation: generation })) },
350
359
  async getSetting (key) { const r = await one(db.from('app_settings').select('value').eq('key', key).maybeSingle()); return r ? r.value : null },
351
360
  async setSetting (key, value) { await one(db.from('app_settings').upsert({ key, value: String(value), updated_at: new Date().toISOString() }, { onConflict: 'key' })) },
352
361
  async setSettingIfAbsent (key, value) {
@@ -417,6 +426,36 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
417
426
  return u ? { email: u.email || '', confirmed: !!u.email_confirmed_at } : null
418
427
  },
419
428
 
429
+ // Email notices (notify.js): kept on each account, so no table is needed. user_metadata.notify is what
430
+ // the person turned on (the website writes it); user_metadata.notify_sent what was sent already.
431
+ async notifyUsers () {
432
+ const out = []
433
+ for (let page = 1; page < 1000; page++) {
434
+ const { data, error } = await db.auth.admin.listUsers({ page, perPage: 1000 })
435
+ if (error) throw error
436
+ const list = data?.users || []
437
+ for (const u of list) {
438
+ const m = u.user_metadata || {}
439
+ if (u.email && u.email_confirmed_at && m.notify && typeof m.notify === 'object') out.push({ userId: u.id, email: u.email, notify: m.notify, sent: m.notify_sent || {} })
440
+ }
441
+ if (list.length < 1000) break
442
+ }
443
+ return out
444
+ },
445
+ async setNotifySent (userId, sent) {
446
+ const { data, error } = await db.auth.admin.getUserById(userId)
447
+ if (error) throw error
448
+ const meta = data?.user?.user_metadata || {}
449
+ const { error: e2 } = await db.auth.admin.updateUserById(userId, { user_metadata: { ...meta, notify_sent: sent } })
450
+ if (e2) throw e2
451
+ },
452
+
453
+ // A person's browser sign-ins (20261026235000_my_browser_sessions.sql), for signing out one at a time.
454
+ async authSessions (userId) {
455
+ return (await one(db.rpc('auth_sessions_of', { p_user: userId }))).map((r) => ({ id: r.id, createdAt: ms(r.created_at), lastActiveAt: ms(r.updated_at || r.created_at), userAgent: r.user_agent || '', ip: r.ip || '' }))
456
+ },
457
+ async endAuthSession (userId, id) { return !!(await one(db.rpc('end_auth_session', { p_user: userId, p_id: id }))) },
458
+
420
459
  // Session activity (see 20261002000000_session_activity.sql). Event times are epoch ms.
421
460
  async ingestPresence (events, receivedAt) {
422
461
  return await one(db.rpc('ingest_presence', { p_events: events, p_received_at: ts(receivedAt) }))
@@ -885,6 +924,16 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
885
924
  await one(db.from('secret_data_keys').upsert({ scope_type: scopeType, scope_id: scopeId, version, sealed_key: sealedKey, provider }, { onConflict: 'scope_type,scope_id,version', ignoreDuplicates: true }))
886
925
  return rowFrom(await one(db.from('secret_data_keys').select(SECRET_DATA_KEY).eq('scope_type', scopeType).eq('scope_id', scopeId).eq('version', version).single()))
887
926
  },
927
+ // "Connect Seam" (routes/seam-connect.js): the person's Seam tokens, one row per Quilt user.
928
+ async seamLink (userId) {
929
+ const r = await one(db.from('seam_links').select('user_id,access_token,refresh_token,scope,expires_at,connected_at').eq('user_id', userId).maybeSingle())
930
+ return r ? { userId: r.user_id, access: r.access_token, refresh: r.refresh_token, scope: r.scope, expiresAt: Date.parse(r.expires_at), connectedAt: Date.parse(r.connected_at) } : null
931
+ },
932
+ async putSeamLink (userId, link) {
933
+ await one(db.from('seam_links').upsert({ user_id: userId, access_token: link.access, refresh_token: link.refresh, scope: link.scope || '', expires_at: ts(link.expiresAt), connected_at: ts(link.connectedAt ?? Date.now()), updated_at: new Date().toISOString() }, { onConflict: 'user_id' }))
934
+ return { userId, access: link.access, refresh: link.refresh, scope: link.scope || '', expiresAt: link.expiresAt, connectedAt: link.connectedAt ?? Date.now() }
935
+ },
936
+ async deleteSeamLink (userId) { await one(db.from('seam_links').delete().eq('user_id', userId)) },
888
937
  async secretSettings (scopeType, scopeId) {
889
938
  const r = rowFrom(await one(db.from('secret_scope_settings').select(SECRET_SETTINGS).eq('scope_type', scopeType).eq('scope_id', scopeId).maybeSingle()))
890
939
  return r || { scopeType, scopeId, inheritOff: false, inheritOffBy: null, inheritOffAt: null, agentsOn: false, agents: 'all', updatedAt: null }