heyquilt 0.3.122 → 0.3.140

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 (166) hide show
  1. package/RELEASES.md +279 -17
  2. package/bin/quilt.js +62 -152
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -2
  5. package/src/account.js +0 -29
  6. package/src/agent-task-workflow.js +2 -2
  7. package/src/agents/common.js +13 -0
  8. package/src/ai-vm.js +9 -1
  9. package/src/api/activity.js +5 -0
  10. package/src/api/memory-store.js +16 -38
  11. package/src/api/notify.js +134 -0
  12. package/src/api/routes/browsers.js +29 -0
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/session-migrate.js +7 -4
  15. package/src/api/routes/sessions.js +73 -1
  16. package/src/api/routes/teams.js +0 -23
  17. package/src/api/server.js +17 -25
  18. package/src/api/supabase-store.js +43 -56
  19. package/src/autowork.js +182 -16
  20. package/src/branch-view.js +6 -131
  21. package/src/connection.js +36 -9
  22. package/src/control.js +131 -48
  23. package/src/duties.js +1 -1
  24. package/src/force-date.js +27 -0
  25. package/src/fsutil.js +13 -3
  26. package/src/github-commit.js +3 -315
  27. package/src/history.js +13 -3
  28. package/src/mcp.js +93 -167
  29. package/src/merge3.js +11 -3
  30. package/src/now-summary.js +113 -0
  31. package/src/pathrules.js +12 -0
  32. package/src/protocol.js +10 -2
  33. package/src/quilt-default.js +84 -0
  34. package/src/quilt-history.js +106 -0
  35. package/src/quilt-remote.js +78 -0
  36. package/src/relay-ask.js +3 -66
  37. package/src/relay-cluster.js +3 -5
  38. package/src/relay-lease.js +6 -2
  39. package/src/relay-mcp.js +154 -432
  40. package/src/relay-prune.js +6 -145
  41. package/src/relay-turn-sync.js +1 -1
  42. package/src/release-words.js +2 -2
  43. package/src/runner.js +20 -7
  44. package/src/seam-access.js +97 -0
  45. package/src/seam-claims.js +53 -0
  46. package/src/seam-url.js +15 -0
  47. package/src/server.js +1516 -1383
  48. package/src/session-follow.js +461 -27
  49. package/src/session-layout3.js +410 -4
  50. package/src/session-migrate.js +15 -11
  51. package/src/session-quilt.js +1 -1
  52. package/src/session-seam.js +233 -20
  53. package/src/session.js +745 -3438
  54. package/src/source-live/bring-in.js +283 -0
  55. package/src/source-live/live-block.js +504 -33
  56. package/src/source-live/live-square.js +16 -5
  57. package/src/status.js +7 -58
  58. package/src/turn-sync.js +15 -2
  59. package/src/ui/agent-guide.js +1 -1
  60. package/src/ui/app.css +85 -20
  61. package/src/ui/app.js +4 -1
  62. package/src/ui/changes.js +5 -14
  63. package/src/ui/chat.js +9 -4
  64. package/src/ui/cloud-ai-model.js +13 -0
  65. package/src/ui/cloud-ai.js +10 -2
  66. package/src/ui/common.js +6 -4
  67. package/src/ui/fileview.js +20 -7
  68. package/src/ui/force-words.js +19 -0
  69. package/src/ui/git-rule.js +9 -28
  70. package/src/ui/home.js +3 -223
  71. package/src/ui/loom-model.js +15 -1
  72. package/src/ui/quiltview.js +213 -0
  73. package/src/ui/seam-model.js +251 -0
  74. package/src/ui/session.js +377 -489
  75. package/src/ui/tree.js +2 -8
  76. package/src/ui/voice-call.js +83 -14
  77. package/src/ui/voice-model.js +39 -0
  78. package/src/ui/voice.js +167 -17
  79. package/src/ui/workspaces.js +2 -122
  80. package/src/ui-server.js +81 -66
  81. package/src/update-check.js +25 -1
  82. package/src/voice.js +13 -6
  83. package/src/api/routes/github.js +0 -393
  84. package/src/branches.js +0 -194
  85. package/src/clash.js +0 -128
  86. package/src/commit-request.js +0 -59
  87. package/src/commit.js +0 -213
  88. package/src/git-hook.js +0 -48
  89. package/src/git.js +0 -147
  90. package/src/gitignore.js +0 -49
  91. package/src/gitstate.js +0 -1033
  92. package/src/quilts/AGENTS.md +0 -1
  93. package/src/quilts/CLAUDE.md +0 -1
  94. package/src/quilts/accounts.js +0 -136
  95. package/src/quilts/affected.js +0 -132
  96. package/src/quilts/archiver.js +0 -175
  97. package/src/quilts/autorevert.js +0 -49
  98. package/src/quilts/bisect-run.js +0 -31
  99. package/src/quilts/blame.js +0 -103
  100. package/src/quilts/block.js +0 -347
  101. package/src/quilts/bucket.js +0 -25
  102. package/src/quilts/cbor.js +0 -92
  103. package/src/quilts/checks.js +0 -308
  104. package/src/quilts/claims.js +0 -144
  105. package/src/quilts/cli.js +0 -536
  106. package/src/quilts/client.js +0 -137
  107. package/src/quilts/conflict-format.js +0 -190
  108. package/src/quilts/describe.js +0 -296
  109. package/src/quilts/dogfood.js +0 -82
  110. package/src/quilts/errors.js +0 -8
  111. package/src/quilts/events.js +0 -171
  112. package/src/quilts/export-git.js +0 -130
  113. package/src/quilts/export-remote.js +0 -156
  114. package/src/quilts/export.js +0 -81
  115. package/src/quilts/fastmerge.js +0 -41
  116. package/src/quilts/hash.js +0 -44
  117. package/src/quilts/history.js +0 -266
  118. package/src/quilts/ids.js +0 -12
  119. package/src/quilts/import-follow.js +0 -236
  120. package/src/quilts/import-git.js +0 -294
  121. package/src/quilts/import.js +0 -196
  122. package/src/quilts/index.js +0 -94
  123. package/src/quilts/keys.js +0 -186
  124. package/src/quilts/limits.js +0 -67
  125. package/src/quilts/lines.js +0 -49
  126. package/src/quilts/live.js +0 -50
  127. package/src/quilts/merge-smart.js +0 -114
  128. package/src/quilts/merge.js +0 -501
  129. package/src/quilts/oauth.js +0 -202
  130. package/src/quilts/objects.js +0 -144
  131. package/src/quilts/pack.js +0 -118
  132. package/src/quilts/records.js +0 -211
  133. package/src/quilts/resolutions.js +0 -69
  134. package/src/quilts/reviews.js +0 -208
  135. package/src/quilts/routes.js +0 -186
  136. package/src/quilts/runner.js +0 -95
  137. package/src/quilts/server.js +0 -144
  138. package/src/quilts/service.js +0 -963
  139. package/src/quilts/setup.js +0 -41
  140. package/src/quilts/signing.js +0 -149
  141. package/src/quilts/squares.js +0 -239
  142. package/src/quilts/store-memory.js +0 -844
  143. package/src/quilts/store-rpc.js +0 -90
  144. package/src/quilts/store-supabase.js +0 -13
  145. package/src/quilts/tags.js +0 -64
  146. package/src/quilts/textdiff.js +0 -96
  147. package/src/quilts/tokens.js +0 -66
  148. package/src/quilts/tools.js +0 -403
  149. package/src/quilts/tree.js +0 -351
  150. package/src/quilts/webhook.js +0 -116
  151. package/src/quilts/window.js +0 -121
  152. package/src/quilts/xdiff.js +0 -435
  153. package/src/relay-ci.js +0 -111
  154. package/src/relay-clash.js +0 -214
  155. package/src/relay-commit.js +0 -918
  156. package/src/relay-filekey.js +0 -46
  157. package/src/relay-git.js +0 -274
  158. package/src/relay-githttp.js +0 -166
  159. package/src/relay-github.js +0 -109
  160. package/src/relay-outbound.js +0 -1295
  161. package/src/relay-repo-moves.js +0 -43
  162. package/src/relay-sync.js +0 -795
  163. package/src/relay-upstream.js +0 -889
  164. package/src/sync-words.js +0 -42
  165. package/src/ui/branches.js +0 -111
  166. package/src/upstream.js +0 -192
@@ -0,0 +1,134 @@
1
+ // Email notices from the website dashboard's Settings › Notifications (plans/dashboard ticket 11). Each
2
+ // person picks what they want (kept on their account: user_metadata.notify), and the API's leader looks
3
+ // every few minutes: messages to them nobody saw for a while (a mention or a direct message not answered
4
+ // after MENTION_DELAY_MS), an agent asking to join a session they own, a daily digest of tasks for them,
5
+ // and a weekly summary of their time in sessions. What's live comes from the relays (nowAsker in
6
+ // routes/sessions.js), the week from the sessions the relays reported. What was already sent is kept on
7
+ // the account too (user_metadata.notify_sent), so nothing is sent twice, across restarts as well.
8
+
9
+ export const NOTIFY_KINDS = ['mentions', 'joins', 'tasks', 'weekly']
10
+ export const NOTIFY_EVERY_MS = 10 * 60 * 1000
11
+ export const MENTION_DELAY_MS = 15 * 60 * 1000
12
+ const DAY_MS = 24 * 60 * 60 * 1000
13
+ export const DIGEST_MS = DAY_MS
14
+ export const WEEKLY_MS = 7 * DAY_MS
15
+
16
+ /** A person's choices: each kind on or off (off unless it was turned on). */
17
+ export function cleanNotify (v) {
18
+ const o = v && typeof v === 'object' ? v : {}
19
+ return Object.fromEntries(NOTIFY_KINDS.map((k) => [k, o[k] === true]))
20
+ }
21
+
22
+ /** What was sent already: the newest mention and join request mailed, and when the digest and summary went. */
23
+ export function cleanSent (v) {
24
+ const o = v && typeof v === 'object' ? v : {}
25
+ const n = (x) => (Number.isFinite(x) && x > 0 ? x : 0)
26
+ return { mentionsAt: n(o.mentionsAt), joinsAt: n(o.joinsAt), tasksAt: n(o.tasksAt), weeklyAt: n(o.weeklyAt) }
27
+ }
28
+
29
+ const hours = (ms) => {
30
+ const m = Math.round(ms / 60000)
31
+ if (m < 60) return `${m}m`
32
+ return `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ''}`
33
+ }
34
+ const line = (n, s) => `- ${s.name || 'Untitled session'}: ${n.by} · "${n.text}"`
35
+ const footer = (site) => ['', `Open your dashboard: ${site}/dashboard`, `Change which emails you get: ${site}/settings/notifications`]
36
+
37
+ /**
38
+ * The emails due for one person at `t`, and what to remember as sent. `now` is their summary of now
39
+ * (nowAsker), `overview` their week (overviewOf) or null; `site` the website's address.
40
+ */
41
+ export function planNotices ({ notify, sent, now, overview = null, t, site }) {
42
+ const want = cleanNotify(notify)
43
+ const was = cleanSent(sent)
44
+ const next = { ...was }
45
+ const mails = []
46
+ const needs = []
47
+ for (const s of (now && now.sessions) || []) for (const n of s.needs || []) needs.push({ n, s })
48
+
49
+ if (want.mentions) {
50
+ const due = needs.filter(({ n }) => (n.kind === 'mention' || n.kind === 'dm') && n.at > was.mentionsAt && n.at <= t - MENTION_DELAY_MS)
51
+ if (due.length) {
52
+ next.mentionsAt = Math.max(...due.map(({ n }) => n.at))
53
+ mails.push({
54
+ subject: due.length === 1 ? `${due[0].n.by} is waiting for you in ${due[0].s.name || 'Quilt'}` : `${due.length} messages are waiting for you in Quilt`,
55
+ text: ['Messages to you nobody has answered yet:', '', ...due.map(({ n, s }) => line(n, s)), '', 'Answer them in the Quilt app.', ...footer(site)].join('\n')
56
+ })
57
+ }
58
+ }
59
+ if (want.joins) {
60
+ const due = needs.filter(({ n }) => n.kind === 'join' && n.at > was.joinsAt)
61
+ if (due.length) {
62
+ next.joinsAt = Math.max(...due.map(({ n }) => n.at))
63
+ mails.push({
64
+ subject: due.length === 1 ? `${due[0].n.by} asks to join ${due[0].s.name || 'your session'}` : `${due.length} are asking to join your sessions`,
65
+ text: ['Waiting for you to let them in:', '', ...due.map(({ n, s }) => `- ${n.by}${n.agent ? ' (an agent)' : ''} in ${s.name || 'Untitled session'}`), '', 'Let them in, or not, from the session in the Quilt app.', ...footer(site)].join('\n')
66
+ })
67
+ }
68
+ }
69
+ if (want.tasks && t - was.tasksAt >= DIGEST_MS) {
70
+ const due = needs.filter(({ n }) => n.kind === 'task')
71
+ if (due.length) {
72
+ next.tasksAt = t
73
+ mails.push({
74
+ subject: `${due.length} ${due.length === 1 ? 'task is' : 'tasks are'} waiting for you in Quilt`,
75
+ text: ['Tasks for you, still to do:', '', ...due.map(({ n, s }) => `- ${n.text} (${s.name || 'Untitled session'}, from ${n.by})`), ...footer(site)].join('\n')
76
+ })
77
+ }
78
+ }
79
+ if (want.weekly && t - was.weeklyAt >= WEEKLY_MS && overview) {
80
+ next.weeklyAt = t
81
+ const week = overview.sessions.filter((s) => s.lastActiveAt >= t - WEEKLY_MS)
82
+ const together = overview.totals?.collaboratingThisWeek || 0
83
+ if (week.length) {
84
+ const top = (overview.totals?.topCollaborators || []).slice(0, 3).map((c) => c.name)
85
+ mails.push({
86
+ subject: `Your week in Quilt: ${week.length} ${week.length === 1 ? 'session' : 'sessions'}${together ? `, ${hours(together)} together` : ''}`,
87
+ text: [
88
+ `This week you worked in ${week.length} ${week.length === 1 ? 'session' : 'sessions'}${together ? ` and spent ${hours(together)} working with others` : ''}.`,
89
+ ...(top.length ? [`You worked most with ${top.join(', ')}.`] : []),
90
+ '',
91
+ ...week.slice(0, 10).map((s) => `- ${s.name || 'Untitled session'}: ${hours(s.myTotalMs)} for you`),
92
+ ...footer(site)
93
+ ].join('\n')
94
+ })
95
+ }
96
+ }
97
+ return { mails, sent: next }
98
+ }
99
+
100
+ /**
101
+ * The job: every `everyMs` on the leading machine, each person who turned a notice on gets what's due.
102
+ * store.notifyUsers() lists them ({ userId, email, notify, sent }); store.setNotifySent keeps what went.
103
+ */
104
+ export function notifier ({ store, mailer, ask, overview, now = Date.now, log = () => {}, site, leads = () => true, everyMs = NOTIFY_EVERY_MS }) {
105
+ let timer = null
106
+ let running = false
107
+ async function tick () {
108
+ if (running || !leads() || !store.notifyUsers) return 0
109
+ running = true
110
+ let sentCount = 0
111
+ try {
112
+ const users = await store.notifyUsers()
113
+ for (const u of users) {
114
+ const want = cleanNotify(u.notify)
115
+ if (!u.email || !NOTIFY_KINDS.some((k) => want[k])) continue
116
+ const t = now()
117
+ const account = `person:${u.userId}`
118
+ try {
119
+ const live = want.mentions || want.joins || want.tasks ? await ask(account, t) : null
120
+ const week = want.weekly && t - cleanSent(u.sent).weeklyAt >= WEEKLY_MS ? await overview(account, t) : null
121
+ const plan = planNotices({ notify: want, sent: u.sent, now: live, overview: week, t, site })
122
+ for (const m of plan.mails) { await mailer.send({ to: u.email, ...m }); sentCount++ }
123
+ if (JSON.stringify(plan.sent) !== JSON.stringify(cleanSent(u.sent))) await store.setNotifySent(u.userId, plan.sent)
124
+ } catch (err) { log(`notify ${u.userId}: ${err.message}`) }
125
+ }
126
+ } catch (err) { log(`notify: ${err.message}`) } finally { running = false }
127
+ return sentCount
128
+ }
129
+ return {
130
+ tick,
131
+ start () { if (!timer) { timer = setInterval(tick, everyMs); timer.unref?.() } },
132
+ stop () { clearInterval(timer); timer = null }
133
+ }
134
+ }
@@ -0,0 +1,29 @@
1
+ // Signing out one browser at a time (plans/dashboard ticket 12, the website's Settings › Security):
2
+ // your browser sign-ins (Supabase Auth's sessions), and ending one. Computers are unlinked on their own
3
+ // (the devices table). Needs the 20261026235000_my_browser_sessions migration; without it, 503.
4
+ import { HttpError, UUID } from '../http.js'
5
+
6
+ const NOT_SET_UP = 'Signing out one browser at a time is not set up on this server yet.'
7
+
8
+ export function browserRoutes ({ store, person, log = () => {} }) {
9
+ const guard = async (fn) => {
10
+ try { return await fn() } catch (err) {
11
+ if (err instanceof HttpError) throw err
12
+ log(`browsers: ${err.message}`)
13
+ throw new HttpError(503, NOT_SET_UP)
14
+ }
15
+ }
16
+ return [
17
+ ['GET', /^\/v1\/me\/browsers$/, async (req) => {
18
+ const { userId } = await person(req)
19
+ return guard(async () => ({ browsers: await store.authSessions(userId) }))
20
+ }],
21
+ ['DELETE', /^\/v1\/me\/browsers\/([^/]+)$/, async (req, body, [id]) => {
22
+ const { userId } = await person(req)
23
+ if (!UUID.test(id)) throw new HttpError(404, 'no such sign-in')
24
+ const ended = await guard(() => store.endAuthSession(userId, id.toLowerCase()))
25
+ if (!ended) throw new HttpError(404, 'no such sign-in')
26
+ return { ok: true }
27
+ }]
28
+ ]
29
+ }
@@ -15,6 +15,18 @@ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
15
15
  const ACCOUNT = /^(person|agent):[A-Za-z0-9_-]{1,64}$/
16
16
 
17
17
  const VIA = ['app', 'hosted']
18
+ // Relay leases (src/relay-lease.js), moved here from routes/github.js in 16's R2 so they outlive git sync.
19
+ export const LEASE_TTL_MS = 2 * 60 * 1000
20
+ const LEASE_KEY = /^[\x21-\x7e][\x20-\x7e]{0,599}$/
21
+ const LEASE_REPO = /^[A-Za-z0-9.-]{1,100}\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/
22
+
23
+ /** A lease's key: `key` as the relay names it, else (a relay from before R2) "<repo> <branch>", the same key either way; null when neither is one. */
24
+ export function leaseKey (b) {
25
+ if (typeof b.key === 'string' && LEASE_KEY.test(b.key)) return b.key
26
+ const repo = String(b.repo || ''); const branch = String(b.branch || '')
27
+ if (!LEASE_REPO.test(repo) || !branch || branch.length > 255 || /\s/.test(branch)) return null
28
+ return `${repo} ${branch}`
29
+ }
18
30
  /** A short label: printable, at most `n` characters. */
19
31
  const label = (x, n) => stripInvisible(x).slice(0, n).join('').replace(/[\u0000-\u001f\u007f]/g, '').trim()
20
32
 
@@ -51,9 +63,9 @@ export function cleanEvent (e, now) {
51
63
  export function relayRoutes ({ store, now, log, bearer, relaySecret }) {
52
64
  let prunedAt = 0
53
65
  const fromRelay = (req) => {
54
- if (!relaySecret) throw new HttpError(503, 'presence is not set up on this server')
66
+ if (!relaySecret) throw new HttpError(503, 'the relay is not set up on this server')
55
67
  // Hashed first so the comparison takes the same time whatever was sent.
56
- if (!crypto.timingSafeEqual(digest(bearer(req)), digest(relaySecret))) throw new HttpError(401, 'only the relay may report presence')
68
+ if (!crypto.timingSafeEqual(digest(bearer(req)), digest(relaySecret))) throw new HttpError(401, 'only the relay may ask for this')
57
69
  }
58
70
 
59
71
  return [
@@ -71,6 +83,17 @@ export function relayRoutes ({ store, now, log, bearer, relaySecret }) {
71
83
  .catch((err) => log(`could not prune session activity: ${err?.message || err}`))
72
84
  }
73
85
  return { ok: true, applied, skipped: body.events.length - events.length }
74
- }, { maxBody: PRESENCE_BODY }]
86
+ }, { maxBody: PRESENCE_BODY }],
87
+ // One holder at a time per key, across relay machines (relay-lease.js). Leases lapse LEASE_TTL_MS after the last heartbeat.
88
+ ['POST', /^\/v1\/relay\/leases\/(take|heartbeat|release)$/, async (req, b, [what]) => {
89
+ fromRelay(req)
90
+ const key = leaseKey(b); const room = String(b.room || '')
91
+ if (!key || !/^[A-Za-z0-9_-]{1,64}$/.test(room)) throw new HttpError(400, 'give key (or repo as host/owner/name and branch) and room')
92
+ const ttlMs = Math.max(1000, Math.min(10 * 60_000, Number(b.ttlMs) || LEASE_TTL_MS))
93
+ const generation = Number.isInteger(b.generation) ? b.generation : 0
94
+ if (what === 'take') return store.takeLease({ key, room, holder: String(b.holder || '').slice(0, 100), ttlMs })
95
+ if (what === 'heartbeat') return store.heartbeatLease({ key, room, generation, ttlMs })
96
+ return store.releaseLease({ key, room, generation })
97
+ }]
75
98
  ]
76
99
  }
@@ -1,6 +1,7 @@
1
- // Making a session a quilt (plans/quilt-source-control/16, "How a session becomes a quilt"):
1
+ // Making a session a quilt (Seam's plans/quilt-source-control/16, "How a session becomes a quilt"):
2
2
  // POST /v1/sessions/:room/migrate { dry_run, token, step }. The session's owner, signed in, asks here
3
- // (step 'switch' moves the session's files into its squares, layout 3; 'unswitch' goes back);
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);
4
5
  // the relay machine holding the room does the work (POST /quilt/migrate, with the relay secret),
5
6
  // so this is the same operation as the MCP tool and `quilt session migrate`. Seam is called as the
6
7
  // owner: with `token` (their Seam sign-in) when given, else the sign-in they used here. The relay
@@ -23,8 +24,10 @@ export function sessionMigrateRoutes ({ store, person, bearer, relaySecret, clus
23
24
  const dry = body && body.dry_run === true
24
25
  const token = body && typeof body.token === 'string' && body.token ? body.token : bearer(req)
25
26
  const where = await store.relayRoom(room).catch(() => null)
26
- const step = body && (body.step === 'switch' || body.step === 'unswitch') ? body.step : null
27
- const ask = { room, account, ...(dry ? { dry_run: true } : { token }), ...(step && !dry ? { step } : {}) }
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) } : {}) }
28
31
  try {
29
32
  return await cluster.callRelay((where && where.machineId) || '', 'POST', '/quilt/migrate', ask, { timeoutMs: MIGRATE_TIMEOUT_MS })
30
33
  } catch (err) {
@@ -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
+ }
@@ -37,8 +37,6 @@ export function teamRoutes ({ store, user, policyChanged = null }) {
37
37
  name: t.name,
38
38
  createdAt: t.createdAt,
39
39
  access: mine.get(t.id) || null,
40
- // The team's git rule (git for everyone: '' sets nothing, off, branches or any).
41
- git: (await store.teamGithub(t.id)).agentCommits,
42
40
  members: a.can('team_members', 'r') || mine.has(t.id)
43
41
  ? (await store.listTeamMembers(t.id)).map((m) => ({ memberId: m.memberId, name: m.name, access: m.access, kind: m.kind, scopes: m.scopes || [] }))
44
42
  : null
@@ -98,27 +96,6 @@ export function teamRoutes ({ store, user, policyChanged = null }) {
98
96
  return { member: memberView(tm) }
99
97
  }],
100
98
 
101
- // The team's git rule (docs/superpowers/specs/2026-10-17-git-for-everyone.md): whether git is on for
102
- // everyone in the sessions its people own in the org's workspaces, and what agents may push there.
103
- // '' sets nothing; off, branches or any is a ceiling for the workspaces' and sessions' own choices.
104
- ['GET', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/github$/, async (req, body, [slug, id]) => {
105
- const a = await orgFor(req, slug)
106
- const team = await teamIn(a, id)
107
- const mine = (await store.teamsOfMember(a.me.id)).some((x) => x.teamId === team.id)
108
- if (!a.can('teams', 'r') && !mine) throw new HttpError(404, 'no such team')
109
- return store.teamGithub(team.id)
110
- }],
111
- ['PUT', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/github$/, async (req, body, [slug, id]) => {
112
- const a = await orgFor(req, slug)
113
- a.need('teams', 'u')
114
- const team = await teamIn(a, id)
115
- if (!('agentCommits' in body)) throw new HttpError(400, 'Give agentCommits: "" (the team sets nothing), off, branches or any.')
116
- const v = body.agentCommits === null ? '' : body.agentCommits
117
- if (!['', 'off', 'branches', 'any'].includes(v)) throw new HttpError(400, 'agentCommits is "" (the team sets nothing), off, branches or any.')
118
- await store.setTeamGithub(team.id, { agentCommits: v })
119
- if (policyChanged) policyChanged()
120
- return store.teamGithub(team.id)
121
- }],
122
99
 
123
100
  ['DELETE', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/members\/([^/]+)$/, async (req, body, [slug, id, memberId]) => {
124
101
  const a = await orgFor(req, slug)
package/src/api/server.js CHANGED
@@ -25,10 +25,10 @@ import { relayRoutes } from './routes/relay.js'
25
25
  import { relayClusterRoutes } from './routes/relay-cluster.js'
26
26
  import { relayCluster } from './relay-cluster.js'
27
27
  import crypto from 'node:crypto'
28
- import { githubRoutes } from './routes/github.js'
29
- import { githubApp, ensureWebhookSecret, redeliverFailed } from './github-app.js'
30
28
  import { releaseSource } from './releases.js'
31
- import { sessionRoutes } from './routes/sessions.js'
29
+ import { sessionRoutes, nowRoutes, nowAsker, overviewOf } from './routes/sessions.js'
30
+ import { notifier, NOTIFY_EVERY_MS } from './notify.js'
31
+ import { browserRoutes } from './routes/browsers.js'
32
32
  import { sessionMigrateRoutes } from './routes/session-migrate.js'
33
33
  import { accessTypeRoutes } from './routes/access-types.js'
34
34
  import { grantRoutes } from './routes/grants.js'
@@ -74,7 +74,7 @@ const MCP_TIMEOUT_MS = 30 * 1000
74
74
  // a change to its grant reaches it as soon as it reaches a connected app.
75
75
  const PASS_REUSE_MARGIN_MS = 5 * 60 * 1000
76
76
 
77
- 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, seamUrl = 'https://api.seam.heyquilt.com', seamFetch = globalThis.fetch, seamClientId = '', seamSiteUrl = 'https://seam.heyquilt.com', seamConnectSecret = '' }) {
77
+ 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 }) {
78
78
  // PASS_SIGNING_KEY. A bad one should stop the API at start, not fail every pass later.
79
79
  if (passKey) passPublicKey(passKey)
80
80
  const site = String(siteUrl || '').replace(/\/+$/, '')
@@ -223,7 +223,8 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
223
223
  ['GET', /^\/healthz$/, async () => ({ ok: true })],
224
224
 
225
225
  // Which optional parts this API has on, for the app and the MCPs to decide what to show. Public.
226
- ['GET', /^\/v1\/features$/, async () => ({ workspaces: !!workspaces, secrets: !!vaultSecret })],
226
+ // 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.
227
+ ['GET', /^\/v1\/features$/, async () => ({ workspaces: !!workspaces, secrets: !!vaultSecret, quilt_source_default: !!quiltSourceDefault })],
227
228
 
228
229
  ['POST', /^\/v1\/device\/start$/, async (req, body) => {
229
230
  limitStarts(req)
@@ -358,8 +359,6 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
358
359
  // The relay on many machines: where each room lives, and heavy rooms' own machines (relay-cluster.js).
359
360
  const cluster = relayCluster({ store, relayUrl: relay, relaySecret, fetch: relayFetch || githubFetch, now, log, fly: relayFly, ...(heavy ? { limits: heavy } : {}), ...(relayTargets ? { targets: relayTargets } : {}), ...clusterTiming })
360
361
  const ctx = { store, user, person, device, bearer, now, site, apiUrl: api, mailer, log, limit: limitInvites, limitSend: limitInviteSend, limitTokens, limitStarts, spendResume, resumeWindowMs: RESUME_WINDOW_MS, limitJoin, agentAuth, reportKey, limitReports, relaySecret, files, maxFileBytes, workspaceQuotaBytes, maxWorkspaceFiles }
361
- // GitHub's webhook secret: set on the app by this API at start (github-app.js), kept in the store when the environment gives none.
362
- const hookSecret = { value: (github && github.webhookSecret) || '' }
363
362
  // On several machines, one leads what lives in a machine's memory (leader.js); alone, this one does.
364
363
  const lead = leader({ store, machineId, log, ...leaderTiming })
365
364
  // Scaling down (autoscale.js): an idle machine shuts itself down cleanly when the others can carry
@@ -378,24 +377,17 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
378
377
  exit()
379
378
  }
380
379
  })
381
- const githubReady = (async () => {
382
- if (!github) return
383
- const app = githubApp(github, { fetch: githubFetch, now })
384
- await ensureWebhookSecret({ cfg: github, app, store, hook: hookSecret, log })
385
- // Asking GitHub for failed deliveries again is done once, by the leader, not by every machine.
386
- await lead.ready
387
- if (lead.leads()) await redeliverFailed({ app, log })
388
- })().catch((err) => log(`github at start: ${err.message}`))
380
+ // Sessions are quilts (Seam): nothing waits on GitHub at start.
381
+ const githubReady = Promise.resolve()
389
382
  // A person's AI on its own cloud machine (routes/ai-vm.js): off (503) without Fly set up. Only the leader runs it.
390
383
  const aiVm = aiVmRoutes({ ...ctx, relayUrl, fly, waitMs: aiVmWaitMs, spareSuspendMs: aiVmSpareSuspendMs, leads: lead.leads })
391
384
  routes.push(...aiVm.routes)
392
- // A team's git rule or who is on a team changed: the relay asks its sessions' rules again now (relay /github/policy).
393
- // With a room (a session put in or taken out of a workspace), only that session asks.
394
- const policyChanged = (room = '') => {
395
- if (!relay || !relaySecret) return
396
- cluster.tellRelays('/github/policy', room ? { room } : {}).catch((err) => log(`git rules to relay: ${err.message}`))
397
- }
398
- 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), ...sessionMigrateRoutes({ ...ctx, cluster }), ...accessTypeRoutes(ctx), ...grantRoutes({ ...ctx, workspaces }), ...sessionInviteRoutes({ ...ctx, workspaces }), ...issueRoutes(ctx))
385
+ // Email notices people turned on in the website's Settings › Notifications (notify.js): the leader sends them.
386
+ 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 })
387
+ if (notifyEveryMs > 0) notices.start()
388
+ // Git rules went with git mode (R3): nothing to tell the relay.
389
+ const policyChanged = () => {}
390
+ routes.push(...orgRoutes(ctx), ...memberRoutes(ctx), ...teamRoutes({ ...ctx, policyChanged }), ...inviteRoutes(ctx), ...agentRoutes({ ...ctx, passKey }), ...agentInviteRoutes({ ...ctx, workspaces }), ...joinRoutes(ctx), ...relayRoutes(ctx), ...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))
399
391
  // Always routed: with the flag off each answers a plain 404 of its own, so the app's
400
392
  // check at every launch isn't filed as a missing route.
401
393
  // Only these take a hosted agent's pass (workspaceReach), so they alone get the pass key.
@@ -417,7 +409,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
417
409
  routes.push(...mailRoutes({ ...ctx, passKey, mail, mailFetch, makeLimiter, tellRoom }))
418
410
  // How Quilt wakes each agent (routes/agent-wake.js): step 2 of joining.
419
411
  routes.push(...agentWakeRoutes({ ...ctx, passKey, mail, mailFetch, makeLimiter }))
420
- // Seam (src/quilts/, plans/quilt-source-control/) is its own service at seamUrl. This API is its
412
+ // Seam (src/quilts/, Seam's plans/quilt-source-control/) is its own service at seamUrl. This API is its
421
413
  // identity service for Quilt accounts: GET /v1/seam/me says who is calling, as a quilt member
422
414
  // { member_id, kind, name, tool?, on_behalf_of? }, for a person's sign-in, an agent key or a pass.
423
415
  const seamCaller = workspaceReach({ ...ctx, passKey })
@@ -519,7 +511,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
519
511
  }
520
512
  })
521
513
 
522
- // Seam's routes moved to Seam's own server (docs/seam-service.md): old seam commands and sites
514
+ // Seam's routes moved to Seam's own server (Seam's docs/seam-service.md): old seam commands and sites
523
515
  // still call them here, so they go on to seamUrl as sent, and Seam's answer comes straight back.
524
516
  async function forwardSeam (req, res) {
525
517
  if (!seamUrl) throw new HttpError(404, 'not found')
@@ -698,7 +690,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
698
690
 
699
691
  return new Promise((resolve) => server.listen(port, host, () => {
700
692
  const p = server.address().port
701
- 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 })
693
+ 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 })
702
694
  }))
703
695
  }
704
696
 
@@ -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) => {
@@ -290,63 +298,12 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
290
298
  if (error) throw error
291
299
  },
292
300
 
293
- // The GitHub account a person connected (routes/github.js): { githubId, login, linkedAt } or null.
294
- async githubLink (userId) { return rowFrom(await one(db.from('github_links').select('github_id, login, linked_at').eq('user_id', userId).maybeSingle())) },
295
- async setGithubLink (userId, { githubId, login }) {
296
- await one(db.from('github_links').upsert({ user_id: userId, github_id: githubId, login, linked_at: new Date().toISOString() }, { onConflict: 'user_id' }))
297
- },
298
- async deleteGithubLink (userId) { await one(db.from('github_links').delete().eq('user_id', userId)) },
299
- // GitHub connected for a scope (github_connections), newest first.
300
- async githubConnections ({ scope, scopeId }) {
301
- const rows = await one(db.from('github_connections').select('scope, scope_id, connected_by, installation_id, created_at').eq('scope', scope).eq('scope_id', scopeId).order('created_at', { ascending: false }))
302
- return rows.map((r) => ({ scope: r.scope, scopeId: r.scope_id, connectedBy: r.connected_by, installationId: r.installation_id == null ? null : Number(r.installation_id), createdAt: Date.parse(r.created_at) }))
303
- },
304
- async addGithubConnection ({ scope, scopeId, connectedBy, installationId = null }) {
305
- await one(db.from('github_connections').upsert({ scope, scope_id: scopeId, connected_by: connectedBy, installation_id: installationId, created_at: new Date().toISOString() }, { onConflict: 'scope,scope_id,connected_by' }))
306
- },
307
- async removeGithubConnection ({ scope, scopeId, connectedBy }) { await one(db.from('github_connections').delete().eq('scope', scope).eq('scope_id', scopeId).eq('connected_by', connectedBy)) },
308
- async workspaceGithub (workspaceId) {
309
- const r = await one(db.from('workspace_github').select('access, repos, agent_commits').eq('workspace_id', workspaceId).maybeSingle())
310
- return r ? { access: r.access, repos: r.repos || [], agentCommits: r.agent_commits || '' } : { access: 'open', repos: [], agentCommits: '' }
311
- },
312
- // Only what is given changes: { access, repos } and/or { agentCommits } ('' for each session to decide).
313
- async setWorkspaceGithub (workspaceId, { access, repos, agentCommits }) {
314
- const row = { workspace_id: workspaceId, updated_at: new Date().toISOString() }
315
- if (access !== undefined) { row.access = access; row.repos = repos || [] }
316
- if (agentCommits !== undefined) row.agent_commits = agentCommits || null
317
- await one(db.from('workspace_github').upsert(row, { onConflict: 'workspace_id' }))
318
- },
319
- async teamGithub (teamId) {
320
- const r = await one(db.from('team_github').select('agent_commits').eq('team_id', teamId).maybeSingle())
321
- return { agentCommits: (r && r.agent_commits) || '' }
322
- },
323
- async setTeamGithub (teamId, { agentCommits }) {
324
- await one(db.from('team_github').upsert({ team_id: teamId, agent_commits: agentCommits || null, updated_at: new Date().toISOString() }, { onConflict: 'team_id' }))
325
- },
326
- // The rules of the teams `memberId` is on: [{ teamId, name, agentCommits }], only those that set one.
327
- async teamGitRules (memberId) {
328
- const rows = await one(db.from('team_members').select('team_id, teams (name, team_github (agent_commits))').eq('member_id', memberId))
329
- return rows.map((r) => {
330
- const g = r.teams && r.teams.team_github
331
- const mode = Array.isArray(g) ? (g[0] && g[0].agent_commits) : (g && g.agent_commits)
332
- return mode ? { teamId: r.team_id, name: (r.teams && r.teams.name) || '', agentCommits: mode } : null
333
- }).filter(Boolean)
334
- },
335
- async orgGithub (orgId) {
336
- const r = await one(db.from('org_github').select('require_github, agent_commits').eq('org_id', orgId).maybeSingle())
337
- return { requireGithub: !!(r && r.require_github), agentCommits: (r && r.agent_commits) || '' }
338
- },
339
- async setOrgGithub (orgId, { requireGithub, agentCommits }) {
340
- const row = { org_id: orgId, updated_at: new Date().toISOString() }
341
- if (requireGithub !== undefined) row.require_github = !!requireGithub
342
- if (agentCommits !== undefined) row.agent_commits = agentCommits || null
343
- await one(db.from('org_github').upsert(row, { onConflict: 'org_id' }))
344
- },
345
301
  // 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 })) },
302
+ // Relay leases (relay_leases, through its locking functions; 16 R2). `key`: the relay's name for what it holds.
303
+ // Before the migration that makes relay_leases is applied, a git branch's key ("<repo> <branch>") goes to the old functions.
304
+ 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 })) },
305
+ 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 })) },
306
+ 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
307
  async getSetting (key) { const r = await one(db.from('app_settings').select('value').eq('key', key).maybeSingle()); return r ? r.value : null },
351
308
  async setSetting (key, value) { await one(db.from('app_settings').upsert({ key, value: String(value), updated_at: new Date().toISOString() }, { onConflict: 'key' })) },
352
309
  async setSettingIfAbsent (key, value) {
@@ -417,6 +374,36 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
417
374
  return u ? { email: u.email || '', confirmed: !!u.email_confirmed_at } : null
418
375
  },
419
376
 
377
+ // Email notices (notify.js): kept on each account, so no table is needed. user_metadata.notify is what
378
+ // the person turned on (the website writes it); user_metadata.notify_sent what was sent already.
379
+ async notifyUsers () {
380
+ const out = []
381
+ for (let page = 1; page < 1000; page++) {
382
+ const { data, error } = await db.auth.admin.listUsers({ page, perPage: 1000 })
383
+ if (error) throw error
384
+ const list = data?.users || []
385
+ for (const u of list) {
386
+ const m = u.user_metadata || {}
387
+ 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 || {} })
388
+ }
389
+ if (list.length < 1000) break
390
+ }
391
+ return out
392
+ },
393
+ async setNotifySent (userId, sent) {
394
+ const { data, error } = await db.auth.admin.getUserById(userId)
395
+ if (error) throw error
396
+ const meta = data?.user?.user_metadata || {}
397
+ const { error: e2 } = await db.auth.admin.updateUserById(userId, { user_metadata: { ...meta, notify_sent: sent } })
398
+ if (e2) throw e2
399
+ },
400
+
401
+ // A person's browser sign-ins (20261026235000_my_browser_sessions.sql), for signing out one at a time.
402
+ async authSessions (userId) {
403
+ 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 || '' }))
404
+ },
405
+ async endAuthSession (userId, id) { return !!(await one(db.rpc('end_auth_session', { p_user: userId, p_id: id }))) },
406
+
420
407
  // Session activity (see 20261002000000_session_activity.sql). Event times are epoch ms.
421
408
  async ingestPresence (events, receivedAt) {
422
409
  return await one(db.rpc('ingest_presence', { p_events: events, p_received_at: ts(receivedAt) }))