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.
- package/RELEASES.md +279 -17
- package/bin/quilt.js +62 -152
- package/desktop/main.js +16 -2
- package/package.json +3 -2
- package/src/account.js +0 -29
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +16 -38
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/session-migrate.js +7 -4
- package/src/api/routes/sessions.js +73 -1
- package/src/api/routes/teams.js +0 -23
- package/src/api/server.js +17 -25
- package/src/api/supabase-store.js +43 -56
- package/src/autowork.js +182 -16
- package/src/branch-view.js +6 -131
- package/src/connection.js +36 -9
- package/src/control.js +131 -48
- package/src/duties.js +1 -1
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/github-commit.js +3 -315
- package/src/history.js +13 -3
- package/src/mcp.js +93 -167
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-ask.js +3 -66
- package/src/relay-cluster.js +3 -5
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +154 -432
- package/src/relay-prune.js +6 -145
- package/src/relay-turn-sync.js +1 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1516 -1383
- package/src/session-follow.js +461 -27
- package/src/session-layout3.js +410 -4
- package/src/session-migrate.js +15 -11
- package/src/session-quilt.js +1 -1
- package/src/session-seam.js +233 -20
- package/src/session.js +745 -3438
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +504 -33
- package/src/source-live/live-square.js +16 -5
- package/src/status.js +7 -58
- package/src/turn-sync.js +15 -2
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/changes.js +5 -14
- package/src/ui/chat.js +9 -4
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -4
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -28
- package/src/ui/home.js +3 -223
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +377 -489
- package/src/ui/tree.js +2 -8
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui/workspaces.js +2 -122
- package/src/ui-server.js +81 -66
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/api/routes/github.js +0 -393
- package/src/branches.js +0 -194
- package/src/clash.js +0 -128
- package/src/commit-request.js +0 -59
- package/src/commit.js +0 -213
- package/src/git-hook.js +0 -48
- package/src/git.js +0 -147
- package/src/gitignore.js +0 -49
- package/src/gitstate.js +0 -1033
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/accounts.js +0 -136
- package/src/quilts/affected.js +0 -132
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/checks.js +0 -308
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -536
- package/src/quilts/client.js +0 -137
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -296
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -171
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export-remote.js +0 -156
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-follow.js +0 -236
- package/src/quilts/import-git.js +0 -294
- package/src/quilts/import.js +0 -196
- package/src/quilts/index.js +0 -94
- package/src/quilts/keys.js +0 -186
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -501
- package/src/quilts/oauth.js +0 -202
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -208
- package/src/quilts/routes.js +0 -186
- package/src/quilts/runner.js +0 -95
- package/src/quilts/server.js +0 -144
- package/src/quilts/service.js +0 -963
- package/src/quilts/setup.js +0 -41
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -844
- package/src/quilts/store-rpc.js +0 -90
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -403
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -121
- package/src/quilts/xdiff.js +0 -435
- package/src/relay-ci.js +0 -111
- package/src/relay-clash.js +0 -214
- package/src/relay-commit.js +0 -918
- package/src/relay-filekey.js +0 -46
- package/src/relay-git.js +0 -274
- package/src/relay-githttp.js +0 -166
- package/src/relay-github.js +0 -109
- package/src/relay-outbound.js +0 -1295
- package/src/relay-repo-moves.js +0 -43
- package/src/relay-sync.js +0 -795
- package/src/relay-upstream.js +0 -889
- package/src/sync-words.js +0 -42
- package/src/ui/branches.js +0 -111
- 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
|
+
}
|
package/src/api/routes/relay.js
CHANGED
|
@@ -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, '
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/api/routes/teams.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
//
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
//
|
|
347
|
-
|
|
348
|
-
async
|
|
349
|
-
async
|
|
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) }))
|