heyquilt 0.0.1 → 0.3.90

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 (204) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +630 -2
  3. package/RELEASES.md +733 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +816 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +563 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +180 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +170 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +98 -0
  19. package/src/ai-vm.js +380 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +130 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +76 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1083 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +251 -0
  40. package/src/api/relay-machines.js +60 -0
  41. package/src/api/routes/access-types.js +54 -0
  42. package/src/api/routes/agent-invites.js +115 -0
  43. package/src/api/routes/agents.js +157 -0
  44. package/src/api/routes/ai-vm.js +369 -0
  45. package/src/api/routes/github.js +380 -0
  46. package/src/api/routes/grants.js +70 -0
  47. package/src/api/routes/invites.js +188 -0
  48. package/src/api/routes/issues.js +47 -0
  49. package/src/api/routes/join.js +167 -0
  50. package/src/api/routes/members.js +110 -0
  51. package/src/api/routes/orgs.js +177 -0
  52. package/src/api/routes/relay-cluster.js +90 -0
  53. package/src/api/routes/relay.js +76 -0
  54. package/src/api/routes/session-invites.js +119 -0
  55. package/src/api/routes/sessions.js +92 -0
  56. package/src/api/routes/teams.js +132 -0
  57. package/src/api/routes/workspace-agents.js +332 -0
  58. package/src/api/routes/workspace-files.js +277 -0
  59. package/src/api/routes/workspace-invites.js +196 -0
  60. package/src/api/routes/workspaces.js +225 -0
  61. package/src/api/server.js +623 -0
  62. package/src/api/slugs.js +20 -0
  63. package/src/api/supabase-store.js +765 -0
  64. package/src/api/team-access.js +30 -0
  65. package/src/api/tokens.js +31 -0
  66. package/src/api/workspace-access.js +62 -0
  67. package/src/api/workspace-reach.js +60 -0
  68. package/src/autowork-tools.js +166 -0
  69. package/src/autowork.js +387 -0
  70. package/src/blobstore.js +99 -0
  71. package/src/boxes.js +37 -0
  72. package/src/branchdocs.js +235 -0
  73. package/src/branches.js +194 -0
  74. package/src/catchup.js +111 -0
  75. package/src/chat-archive.js +85 -0
  76. package/src/chat-links.js +923 -0
  77. package/src/clash.js +140 -0
  78. package/src/cloud-ai.js +50 -0
  79. package/src/commit-request.js +59 -0
  80. package/src/commit.js +213 -0
  81. package/src/connection.js +569 -0
  82. package/src/control.js +245 -0
  83. package/src/conversation.js +125 -0
  84. package/src/doctor.js +156 -0
  85. package/src/duties.js +303 -0
  86. package/src/editors.js +228 -0
  87. package/src/fsutil.js +87 -0
  88. package/src/git-hook.js +48 -0
  89. package/src/git.js +147 -0
  90. package/src/github-commit.js +320 -0
  91. package/src/gitignore.js +49 -0
  92. package/src/gitstate.js +900 -0
  93. package/src/history.js +307 -0
  94. package/src/hooks.js +371 -0
  95. package/src/hosted-agents.js +24 -0
  96. package/src/hosted-client.js +112 -0
  97. package/src/identity.js +99 -0
  98. package/src/inbox.js +237 -0
  99. package/src/integrations.js +221 -0
  100. package/src/key-cli.js +115 -0
  101. package/src/largefiles.js +91 -0
  102. package/src/legacy.js +25 -0
  103. package/src/mcp-proxy.js +40 -0
  104. package/src/mcp-room.js +32 -0
  105. package/src/mcp.js +1261 -0
  106. package/src/merge3.js +158 -0
  107. package/src/merges.js +148 -0
  108. package/src/pass-source.js +176 -0
  109. package/src/passes.js +64 -0
  110. package/src/pathrules.js +127 -0
  111. package/src/persona.js +101 -0
  112. package/src/presence.js +360 -0
  113. package/src/private-file.js +41 -0
  114. package/src/procs.js +70 -0
  115. package/src/project-rules.js +178 -0
  116. package/src/protocol.js +92 -0
  117. package/src/quiet-warnings.js +9 -0
  118. package/src/relay-ai-vm.js +82 -0
  119. package/src/relay-ask.js +141 -0
  120. package/src/relay-ci.js +111 -0
  121. package/src/relay-clash.js +597 -0
  122. package/src/relay-cluster.js +454 -0
  123. package/src/relay-commit.js +887 -0
  124. package/src/relay-filekey.js +46 -0
  125. package/src/relay-git.js +274 -0
  126. package/src/relay-githttp.js +137 -0
  127. package/src/relay-github.js +109 -0
  128. package/src/relay-lease.js +137 -0
  129. package/src/relay-lfs.js +37 -0
  130. package/src/relay-mcp.js +1679 -0
  131. package/src/relay-metrics.js +75 -0
  132. package/src/relay-outbound.js +1144 -0
  133. package/src/relay-sync.js +719 -0
  134. package/src/relay-turn-sync.js +307 -0
  135. package/src/relay-upstream.js +934 -0
  136. package/src/relay-webhooks.js +111 -0
  137. package/src/releases.js +131 -0
  138. package/src/report.js +129 -0
  139. package/src/runner.js +306 -0
  140. package/src/same-edit.js +18 -0
  141. package/src/server.js +4187 -0
  142. package/src/session-access.js +170 -0
  143. package/src/session-name.js +15 -0
  144. package/src/session.js +7361 -0
  145. package/src/settings.js +46 -0
  146. package/src/setup.js +184 -0
  147. package/src/shell-path.js +46 -0
  148. package/src/status.js +199 -0
  149. package/src/summarize.js +109 -0
  150. package/src/sync-words.js +42 -0
  151. package/src/task-comments.js +92 -0
  152. package/src/tasks.js +670 -0
  153. package/src/textdiff.js +20 -0
  154. package/src/turn-sync-plan.js +142 -0
  155. package/src/turn-sync.js +191 -0
  156. package/src/ui/access-form.js +37 -0
  157. package/src/ui/agent-env-key.js +76 -0
  158. package/src/ui/agent-guide.js +127 -0
  159. package/src/ui/agent-kinds.js +175 -0
  160. package/src/ui/agent-place.js +265 -0
  161. package/src/ui/app.css +2309 -0
  162. package/src/ui/app.js +542 -0
  163. package/src/ui/board.js +465 -0
  164. package/src/ui/boxes.js +76 -0
  165. package/src/ui/branches.js +111 -0
  166. package/src/ui/catchup.js +77 -0
  167. package/src/ui/changes.js +159 -0
  168. package/src/ui/chat.js +266 -0
  169. package/src/ui/cloud-ai-model.js +107 -0
  170. package/src/ui/cloud-ai.js +221 -0
  171. package/src/ui/common.js +431 -0
  172. package/src/ui/csv.js +21 -0
  173. package/src/ui/diffview.js +74 -0
  174. package/src/ui/feed-convs.js +39 -0
  175. package/src/ui/feed.js +174 -0
  176. package/src/ui/files.js +579 -0
  177. package/src/ui/fileview.js +123 -0
  178. package/src/ui/git-rule.js +60 -0
  179. package/src/ui/home.js +1323 -0
  180. package/src/ui/index.html +16 -0
  181. package/src/ui/invite.js +97 -0
  182. package/src/ui/invites.js +79 -0
  183. package/src/ui/loom-model.js +516 -0
  184. package/src/ui/loom.css +349 -0
  185. package/src/ui/loom.js +753 -0
  186. package/src/ui/mark.js +104 -0
  187. package/src/ui/merges.js +158 -0
  188. package/src/ui/rail-model.js +78 -0
  189. package/src/ui/rail.js +173 -0
  190. package/src/ui/releases.js +182 -0
  191. package/src/ui/schedule.js +156 -0
  192. package/src/ui/session.js +3394 -0
  193. package/src/ui/signin.js +75 -0
  194. package/src/ui/sounds.js +110 -0
  195. package/src/ui/tool-logo.js +86 -0
  196. package/src/ui/tree.js +276 -0
  197. package/src/ui/workspaces.js +542 -0
  198. package/src/ui-server.js +1272 -0
  199. package/src/update-check.js +67 -0
  200. package/src/upstream.js +155 -0
  201. package/src/webhooks.js +235 -0
  202. package/src/workspace-cli.js +62 -0
  203. package/src/workspace-tools.js +340 -0
  204. package/index.js +0 -2
@@ -0,0 +1,1083 @@
1
+ // The accounts API's data, in memory. Used by tests and `quilt api --memory`;
2
+ // production uses supabase-store.js, which has the same methods.
3
+ import crypto from 'node:crypto'
4
+ import { FALLBACK_TYPE } from '../session-access.js'
5
+
6
+ const uuid = () => crypto.randomUUID()
7
+ const copy = (o) => (o ? structuredClone(o) : null)
8
+ // Postgres's unique-violation code, which the API turns into a 409.
9
+ const duplicate = (what) => Object.assign(new Error(`${what} already exists`), { code: '23505' })
10
+ // The one unique-violation the API gives its own message: it checks the constraint
11
+ // name, the message and the details for 'public_key', the way a real Postgres error
12
+ // (constraint "agents_public_key_key", detail "Key (public_key)=(...) already exists.") would read.
13
+ const duplicatePublicKey = () => Object.assign(new Error('duplicate key value violates unique constraint "agents_public_key_key"'), {
14
+ code: '23505', constraint: 'agents_public_key_key', details: 'Key (public_key)=(...) already exists.'
15
+ })
16
+ // Postgres's foreign-key-violation code, mirrored for the checks the schema
17
+ // enforces with NO ACTION (a delete blocked by a live reference) and for
18
+ // composite-FK checks at insert time (a row that doesn't point at a valid
19
+ // parent) — `verb` lets each call site read correctly for which case it is.
20
+ const fkViolation = (what, verb = 'is still referenced') => Object.assign(new Error(`${what} ${verb}`), { code: '23503' })
21
+ // Postgres's check-violation code, mirrored for the checks the schema makes.
22
+ const checkViolation = (what) => Object.assign(new Error(what), { code: '23514' })
23
+ const tooManyFolders = (scopes) => { if (scopes.length > 20) throw checkViolation('at most 20 folders') }
24
+
25
+ export function createMemoryStore ({ now = Date.now } = {}) {
26
+ const links = new Map(); const devices = new Map(); const profiles = new Map(); const agents = new Map(); const resumeHashes = new Map() // resume key hash -> agent id
27
+ const githubLinks = new Map() // user id -> { githubId, login, linkedAt }
28
+ const githubConnections = new Map() // "scope\0scopeId\0userId" -> { scope, scopeId, connectedBy, installationId, createdAt }
29
+ const workspaceGithub = new Map() // workspace id -> { access, repos, updatedAt }
30
+ const orgGithub = new Map() // org id -> { requireGithub, updatedAt }
31
+ const teamGithub = new Map() // team id -> { agentCommits, updatedAt }
32
+ const settings = new Map() // the API's own settings (a webhook secret it made), key -> value
33
+ const leases = new Map() // "repo\0branch" -> { room, holder, generation, heartbeatAt } (git_sync_leases)
34
+ const users = new Map(); const orgs = new Map(); const roles = new Map(); const members = new Map()
35
+ const teams = new Map(); const teamMembers = new Map(); const invites = new Map(); const requests = new Map()
36
+ const agentInvites = new Map(); const keyRows = new Map(); const appKeys = new Map()
37
+ const events = new Map(); const issues = new Map()
38
+ const relaySessions = new Map(); const visits = new Map(); const seenEvents = new Map(); const actions = new Map()
39
+ const accessTypes = new Map(); const grants = new Map(); const sessionInvites = new Map()
40
+ const workspaces = new Map(); const workspaceMembers = new Map(); const workspaceInvites = new Map()
41
+ const workspaceFiles = new Map(); const workspaceFileVersions = new Map()
42
+ const agentPlacements = new Map(); const workspaceAgentOverrides = new Map()
43
+ const sessionAgentExclusions = new Map(); const agentWebhooks = new Map()
44
+ const aiVms = new Map() // user id -> the person's cloud AI machine (ai_vms)
45
+ // The relay on many machines (20261017000000_relay_cluster.sql).
46
+ const relayMachines = new Map(); const relayRooms = new Map(); const relayJobs = new Map()
47
+ const hostedRooms = new Map() // "account\nroom" -> { account, room, addedAt }
48
+ const hostedWebhooks = new Map(); const resumeSignatures = new Map()
49
+ const wmKey = (workspaceId, account) => `${workspaceId}\n${account}`
50
+ const grantKey = (room, account) => `${room}\n${account}`
51
+ const waoKey = (workspaceId, agentId) => `${workspaceId}\n${agentId}`
52
+ const saeKey = (room, agentId) => `${room}\n${agentId}`
53
+ const inviteOpenAt = (i, at) => !i.usedAt && !i.cancelledAt && i.expiresAt > at
54
+ const wiWaiting = (i) => !i.acceptedAt && !i.declinedAt && !i.cancelledAt
55
+ // A session invite as the owner's routes see it: its link is only for whoever it was sent to.
56
+ const noLink = (i) => { const { link, ...rest } = copy(i); return rest }
57
+ const all = (m, keep) => [...m.values()].filter(keep)
58
+ const nameOf = (userId) => profiles.get(userId)?.name || ''
59
+ const findMember = (orgId, userId) => all(members, (m) => m.orgId === orgId && m.userId === userId)[0]
60
+ // Mirrors the composite (role_id, org_id) foreign key: a role from another org can't be attached here.
61
+ const roleInOrg = (roleId, orgId) => roleId == null || roles.get(roleId)?.orgId === orgId
62
+ // An invite still holds its role open only while it's neither accepted nor
63
+ // cancelled nor expired; deleteRole and roleInUse agree on this one definition.
64
+ const inviteOpen = (i) => !i.acceptedAt && !i.cancelledAt && i.expiresAt > now()
65
+ // Leaving an org also leaves its teams, like the cascade in Postgres.
66
+ const dropMember = (id) => {
67
+ members.delete(id)
68
+ for (const [k, tm] of teamMembers) if (tm.memberId === id) teamMembers.delete(k)
69
+ }
70
+ // Hard-deletes a workspace file and cascades its versions, like the FK in Postgres.
71
+ const removeFile = (id) => {
72
+ workspaceFiles.delete(id)
73
+ for (const k of [...workspaceFileVersions.keys()]) if (k.startsWith(`${id}\n`)) workspaceFileVersions.delete(k)
74
+ }
75
+ // An org member is a person (named by their profile) or an agent (named when it joined).
76
+ const memberName = (m) => (m?.agentId ? agents.get(m.agentId)?.name || '' : nameOf(m?.userId))
77
+ // Deleting an agent takes its keys and membership with it, like the cascades in
78
+ // Postgres; an invite it used only forgets it (on delete set null). Its workspace
79
+ // placement, any workspace's say over it, session keep-outs and its webhook go too.
80
+ const dropAgent = (id) => {
81
+ agents.delete(id)
82
+ for (const [h, a] of resumeHashes) if (a === id) resumeHashes.delete(h)
83
+ for (const [k, key] of keyRows) if (key.agentId === id) keyRows.delete(k)
84
+ for (const [k, key] of appKeys) if (key.agentId === id) appKeys.delete(k)
85
+ for (const [k, m] of members) if (m.agentId === id) dropMember(k)
86
+ for (const i of agentInvites.values()) if (i.usedByAgentId === id) i.usedByAgentId = null
87
+ agentPlacements.delete(id)
88
+ for (const [k, o] of workspaceAgentOverrides) if (o.agentId === id) workspaceAgentOverrides.delete(k)
89
+ for (const [k, e] of sessionAgentExclusions) if (e.agentId === id) sessionAgentExclusions.delete(k)
90
+ agentWebhooks.delete(id)
91
+ for (const v of aiVms.values()) if (v.agentId === id) v.agentId = null
92
+ }
93
+
94
+ // Deleting an account takes the sessions it owns (with everyone's visits in them) and its
95
+ // own visits, like delete_account_activity.
96
+ const dropActivity = (accounts) => {
97
+ for (const [room, s] of relaySessions) if (accounts.includes(s.ownerAccount)) relaySessions.delete(room)
98
+ for (const [id, v] of visits) if (accounts.includes(v.account) || !relaySessions.has(v.room)) visits.delete(id)
99
+ dropOrphans()
100
+ }
101
+ // Grants, invites and agent keep-outs go with their session (on delete cascade).
102
+ const dropOrphans = () => {
103
+ // What visits did goes with them (on delete cascade).
104
+ const live = new Set(all(visits, () => true).map((v) => v.eventStartId))
105
+ for (const [id, a] of actions) if (!live.has(a.visitStartId)) actions.delete(id)
106
+ for (const [k, g] of grants) if (!relaySessions.has(g.room)) grants.delete(k)
107
+ for (const [id, i] of sessionInvites) if (!relaySessions.has(i.room)) sessionInvites.delete(id)
108
+ for (const [k, e] of sessionAgentExclusions) if (!relaySessions.has(e.room)) sessionAgentExclusions.delete(k)
109
+ }
110
+ // Mirrors delete_account_access.
111
+ const dropAccess = (accounts) => {
112
+ for (const [id, t] of accessTypes) if (accounts.includes(t.ownerAccount)) accessTypes.delete(id)
113
+ for (const [k, g] of grants) if (accounts.includes(g.account)) grants.delete(k)
114
+ for (const [id, i] of sessionInvites) if (accounts.includes(i.account)) sessionInvites.delete(id)
115
+ }
116
+
117
+ return {
118
+ addUser (userId, { name = '', email = '', confirmed = true, kind = 'personal' } = {}) {
119
+ profiles.set(userId, { id: userId, name: name || email.split('@')[0] || 'You', color: null, tool: null, kind })
120
+ users.set(userId, { email, confirmed })
121
+ },
122
+ async createLink (l) {
123
+ const row = { id: uuid(), status: 'pending', userId: null, deviceId: null, createdAt: now(), ...l }
124
+ links.set(row.id, row); return { ...row }
125
+ },
126
+ async linkByDeviceCode (h) { const l = [...links.values()].find((x) => x.deviceCodeHash === h); return l ? { ...l } : null },
127
+ async linkByUserCode (c) { const l = [...links.values()].find((x) => x.userCode === c); return l ? { ...l } : null },
128
+ async updateLink (id, patch) { const l = links.get(id); Object.assign(l, patch); return { ...l } },
129
+ // Check-and-set: only flips status if it's still fromStatus, so two concurrent
130
+ // callers can't both win the same link (e.g. handing out a device token twice).
131
+ async claimLink (id, fromStatus, toStatus) {
132
+ const l = links.get(id)
133
+ if (!l || l.status !== fromStatus) return false
134
+ l.status = toStatus
135
+ return true
136
+ },
137
+ // One row per (account, key): someone else approving a link for this key gets
138
+ // their own row, never this one. A relink retires the old token.
139
+ async upsertDevice ({ userId, name, platform, publicKey }) {
140
+ let d = [...devices.values()].find((x) => x.userId === userId && x.publicKey === publicKey)
141
+ if (d) Object.assign(d, { name, platform, tokenHash: null, revokedAt: null })
142
+ else devices.set((d = { id: uuid(), userId, name, platform, publicKey, tokenHash: null, createdAt: now(), lastSeenAt: now(), revokedAt: null }).id, d)
143
+ return { ...d }
144
+ },
145
+ async setDeviceToken (id, tokenHash) { devices.get(id).tokenHash = tokenHash },
146
+ async deviceByToken (h) { const d = [...devices.values()].find((x) => x.tokenHash === h && !x.revokedAt); return d ? { ...d } : null },
147
+ async touchDevice (id) { devices.get(id).lastSeenAt = now() },
148
+ // The linked (not unlinked) computer holding this key: the most recently seen, if more than one account linked it.
149
+ async deviceByPublicKey (publicKey) {
150
+ const d = [...devices.values()].filter((x) => x.publicKey === publicKey && !x.revokedAt).sort((a, b) => b.lastSeenAt - a.lastSeenAt)[0]
151
+ return d ? { ...d } : null
152
+ },
153
+ // Whether this account has linked this key before (unlinked since or not).
154
+ async userHasDevice (userId, publicKey) { return [...devices.values()].some((x) => x.userId === userId && x.publicKey === publicKey) },
155
+ async revokeDevice (id) { Object.assign(devices.get(id), { revokedAt: now(), tokenHash: null }) },
156
+ async profile (userId) { const p = profiles.get(userId); return p ? { ...p } : null },
157
+ // 'personal' or 'org': set once at sign-up, and the only thing POST /v1/orgs checks.
158
+ async profileKind (userId) { return profiles.get(userId)?.kind || null },
159
+ async updateProfile (userId, patch) {
160
+ const p = profiles.get(userId)
161
+ for (const k of ['name', 'color', 'tool']) if (patch[k] !== undefined) p[k] = patch[k]
162
+ return { ...p }
163
+ },
164
+ // Agents hold their own keys; at most a public key is kept here. Mirrors
165
+ // agents_one_home (a person's or an org's, never both) and the unique public_key.
166
+ async createAgent ({ name, provider, type, description = '', publicKey = null, resumeHash = null, ownerUserId = null, orgId = null, invitedBy = null }) {
167
+ if ((ownerUserId == null) === (orgId == null)) throw Object.assign(new Error('an agent belongs to one person or one org'), { code: '23514' })
168
+ if (publicKey && all(agents, (a) => a.publicKey === publicKey).length) throw duplicatePublicKey()
169
+ const row = { id: uuid(), name, provider, type, description, publicKey, ownerUserId, orgId, invitedBy, createdAt: now(), lastUsedAt: null, revokedAt: null }
170
+ agents.set(row.id, row)
171
+ // Kept apart from the row, like the database never selecting it with the agent.
172
+ if (resumeHash) resumeHashes.set(resumeHash, row.id)
173
+ return copy(row)
174
+ },
175
+ async agentById (id) { return copy(agents.get(id)) },
176
+ async agentByResume (hash) { return copy(agents.get(resumeHashes.get(hash))) },
177
+ async agentByPublicKey (publicKey) { return publicKey ? copy(all(agents, (a) => a.publicKey === publicKey)[0]) : null },
178
+ async listPersonalAgents (userId) {
179
+ return all(agents, (a) => a.ownerUserId === userId && !a.revokedAt).sort((a, b) => a.createdAt - b.createdAt).map(copy)
180
+ },
181
+ // The org's own agents, live ones only, by name (for placement pickers).
182
+ async listOrgAgents (orgId) {
183
+ return all(agents, (a) => a.orgId === orgId && !a.revokedAt).sort((a, b) => a.name.localeCompare(b.name)).map(copy)
184
+ },
185
+ async touchAgent (id) { const a = agents.get(id); if (a) a.lastUsedAt = now() },
186
+ // Revoking an agent kills every key it holds at once.
187
+ async revokeAgent (id) {
188
+ const a = agents.get(id)
189
+ if (!a || a.revokedAt) return false
190
+ a.revokedAt = now()
191
+ for (const k of keyRows.values()) if (k.agentId === id && !k.revokedAt) k.revokedAt = now()
192
+ for (const k of appKeys.values()) if (k.agentId === id && !k.revokedAt) k.revokedAt = now()
193
+ return true
194
+ },
195
+ // An agent coming back with a new invite: its new profile and resume key, and back
196
+ // from revoked. A publicKey left out keeps the one it has.
197
+ async rejoinAgent (id, { name, provider, type, description = '', publicKey = null, resumeHash }) {
198
+ const a = agents.get(id)
199
+ if (!a) throw fkViolation('agent', 'does not exist')
200
+ if (publicKey && all(agents, (x) => x.publicKey === publicKey && x.id !== id).length) throw duplicatePublicKey()
201
+ Object.assign(a, { name, provider, type, description, revokedAt: null }, publicKey ? { publicKey } : {})
202
+ for (const [h, agentId] of resumeHashes) if (agentId === id) resumeHashes.delete(h)
203
+ if (resumeHash) resumeHashes.set(resumeHash, id)
204
+ return copy(a)
205
+ },
206
+ // Only for undoing a half-finished join.
207
+ async deleteAgent (id) { dropAgent(id) },
208
+ // Agent invites: only the token's hash is kept. Mirrors the one-home and
209
+ // no-role-without-an-org checks and the composite (role_id, org_id) key.
210
+ async createAgentInvite ({ tokenHash, ownerUserId = null, orgId = null, createdBy = null, roleId = null, teams = [], expiresAt, workspaceId = null, workspaceAccess = null, workspaceSessions = null, global = false }) {
211
+ if ((ownerUserId == null) === (orgId == null) || (roleId && !orgId)) throw checkViolation('an invite is for one person or one org')
212
+ if (!roleInOrg(roleId, orgId)) throw fkViolation('role', 'is not in this org')
213
+ const row = { id: uuid(), tokenHash, ownerUserId, orgId, createdBy, roleId, teams: copy(teams), expiresAt, workspaceId, workspaceAccess, workspaceSessions, global: !!global, usedAt: null, usedByAgentId: null, rejoined: false, cancelledAt: null, createdAt: now() }
214
+ agentInvites.set(row.id, row); return copy(row)
215
+ },
216
+ async agentInviteByToken (h) { return copy(all(agentInvites, (i) => i.tokenHash === h)[0]) },
217
+ async agentInviteById (id) { return copy(agentInvites.get(id)) },
218
+ async listAgentInvites ({ ownerUserId, orgId }) {
219
+ return all(agentInvites, (i) => (orgId ? i.orgId === orgId : i.ownerUserId === ownerUserId))
220
+ .sort((a, b) => b.createdAt - a.createdAt).slice(0, 50).map(copy)
221
+ },
222
+ // Check-and-set: an invite is used once, and only while it's open.
223
+ async claimAgentInvite (id) {
224
+ const i = agentInvites.get(id)
225
+ if (!i || i.usedAt || i.cancelledAt || i.expiresAt <= now()) return false
226
+ i.usedAt = now(); return true
227
+ },
228
+ // Undoes a claim when making the agent failed, so the link can be tried again.
229
+ async releaseAgentInvite (id) { const i = agentInvites.get(id); if (i) Object.assign(i, { usedAt: null, usedByAgentId: null, rejoined: false }) },
230
+ async setInviteAgent (id, agentId, rejoined = false) { Object.assign(agentInvites.get(id), { usedByAgentId: agentId, rejoined }) },
231
+ // Check-and-set: only a waiting invite is cancelled.
232
+ async cancelAgentInvite (id) {
233
+ const i = agentInvites.get(id)
234
+ if (!i || i.usedAt || i.cancelledAt || i.expiresAt <= now()) return false
235
+ i.cancelledAt = now(); return true
236
+ },
237
+
238
+ // App keys (qk_): hashes only, and they don't run out.
239
+ async createAgentAppKey ({ agentId, name, keyHash }) {
240
+ if (!agents.has(agentId)) throw fkViolation('agent', 'does not exist')
241
+ const row = { id: uuid(), agentId, name, keyHash, createdAt: now(), lastUsedAt: null, revokedAt: null }
242
+ appKeys.set(row.id, row); return copy(row)
243
+ },
244
+ async agentAppKeyByHash (h) { return copy(all(appKeys, (k) => k.keyHash === h)[0]) },
245
+ async listAgentAppKeys (agentId) {
246
+ return all(appKeys, (k) => k.agentId === agentId && !k.revokedAt).sort((a, b) => a.createdAt - b.createdAt).map(copy)
247
+ },
248
+ async touchAgentAppKey (id) { const k = appKeys.get(id); if (k) k.lastUsedAt = now() },
249
+ // Only a key of this agent, and only once.
250
+ async revokeAgentAppKey (agentId, id) {
251
+ const k = appKeys.get(id)
252
+ if (!k || k.agentId !== agentId || k.revokedAt) return false
253
+ k.revokedAt = now(); return true
254
+ },
255
+
256
+ // Agent keys: hashes only.
257
+ async createAgentKeys (k) {
258
+ if (!agents.has(k.agentId)) throw fkViolation('agent', 'does not exist')
259
+ const row = { id: uuid(), refreshedAt: null, revokedAt: null, createdAt: now(), ...k }
260
+ keyRows.set(row.id, row); return copy(row)
261
+ },
262
+ async agentKeyByAccess (h) { return copy(all(keyRows, (k) => k.accessHash === h)[0]) },
263
+ async agentKeyByRefresh (h) { return copy(all(keyRows, (k) => k.refreshHash === h)[0]) },
264
+ async listAgentKeys (agentId) { return all(keyRows, (k) => k.agentId === agentId).map(copy) },
265
+ // Check-and-set: a refresh key is spent once, and never after it was revoked.
266
+ async claimRefresh (id) {
267
+ const k = keyRows.get(id)
268
+ if (!k || k.refreshedAt || k.revokedAt) return false
269
+ k.refreshedAt = now(); return true
270
+ },
271
+ async revokeFamily (familyId) {
272
+ for (const k of keyRows.values()) if (k.familyId === familyId && !k.revokedAt) k.revokedAt = now()
273
+ },
274
+ // Undoes a claim when minting the new pair failed, so the same refresh key can retry.
275
+ async releaseRefresh (id) {
276
+ const k = keyRows.get(id)
277
+ if (k && !k.revokedAt) k.refreshedAt = null
278
+ },
279
+ async deleteUser (userId) {
280
+ const accounts = [`person:${userId}`, ...all(agents, (a) => a.ownerUserId === userId).map((a) => `agent:${a.id}`)]
281
+ for (const [k, c] of githubConnections) if (c.connectedBy === userId || (c.scope === 'person' && c.scopeId === userId)) githubConnections.delete(k)
282
+ dropAccess(accounts)
283
+ dropActivity(accounts)
284
+ profiles.delete(userId); users.delete(userId)
285
+ aiVms.delete(userId)
286
+ for (const [id, d] of devices) if (d.userId === userId) devices.delete(id)
287
+ for (const [id, a] of agents) if (a.ownerUserId === userId) dropAgent(id)
288
+ for (const a of agents.values()) if (a.invitedBy === userId) a.invitedBy = null
289
+ for (const [id, i] of agentInvites) if (i.ownerUserId === userId) agentInvites.delete(id)
290
+ for (const i of agentInvites.values()) if (i.createdBy === userId) i.createdBy = null
291
+ for (const [id, m] of members) if (m.userId === userId) dropMember(id)
292
+ for (const [id, r] of requests) if (r.userId === userId) requests.delete(id)
293
+ for (const [k, w] of workspaces) if (w.ownerUserId === userId) { for (const s of relaySessions.values()) if (s.workspaceId === k) s.workspaceId = null; for (const [mk, m] of workspaceMembers) if (m.workspaceId === k) workspaceMembers.delete(mk); workspaces.delete(k) }
294
+ },
295
+
296
+ // The address a person signs in with, and whether they've confirmed it.
297
+ async userEmail (userId) { const u = users.get(userId); return u ? { ...u } : null },
298
+ async githubLink (userId) { const g = githubLinks.get(userId); return g ? { ...g } : null },
299
+ async setGithubLink (userId, { githubId, login }) { githubLinks.set(userId, { githubId, login, linkedAt: now() }) },
300
+ async deleteGithubLink (userId) { githubLinks.delete(userId) },
301
+ // GitHub connected for a scope (github_connections): an org, a workspace or a person; newest first.
302
+ async githubConnections ({ scope, scopeId }) { return all(githubConnections, (c) => c.scope === scope && c.scopeId === scopeId).sort((a, b) => b.createdAt - a.createdAt).map(copy) },
303
+ async addGithubConnection ({ scope, scopeId, connectedBy, installationId = null }) { githubConnections.set(`${scope}\0${scopeId}\0${connectedBy}`, { scope, scopeId, connectedBy, installationId, createdAt: now() }) },
304
+ async removeGithubConnection ({ scope, scopeId, connectedBy }) { githubConnections.delete(`${scope}\0${scopeId}\0${connectedBy}`) },
305
+ // A workspace's access mode (workspace_github): 'open', or 'repos' with the repositories it allows.
306
+ async workspaceGithub (workspaceId) { const w = workspaceGithub.get(workspaceId); return w ? { access: w.access, repos: [...w.repos], agentCommits: w.agentCommits || '' } : { access: 'open', repos: [], agentCommits: '' } },
307
+ // Only what is given changes: { access, repos } and/or { agentCommits } ('' for each session to decide).
308
+ async setWorkspaceGithub (workspaceId, { access, repos, agentCommits }) {
309
+ const w = workspaceGithub.get(workspaceId) || { access: 'open', repos: [], agentCommits: '' }
310
+ workspaceGithub.set(workspaceId, { access: access ?? w.access, repos: repos ? [...repos] : w.repos, agentCommits: agentCommits ?? w.agentCommits ?? '', updatedAt: now() })
311
+ },
312
+ // An org's GitHub switch (org_github): whether it requires GitHub.
313
+ async teamGithub (teamId) { const r = teamGithub.get(teamId); return { agentCommits: (r && r.agentCommits) || '' } },
314
+ async setTeamGithub (teamId, { agentCommits }) { teamGithub.set(teamId, { agentCommits: agentCommits || '', updatedAt: now() }) },
315
+ // The rules of the teams `memberId` is on: [{ teamId, name, agentCommits }], only those that set one.
316
+ async teamGitRules (memberId) {
317
+ return all(teamMembers, (tm) => tm.memberId === memberId)
318
+ .map((tm) => ({ team: teams.get(tm.teamId), rule: teamGithub.get(tm.teamId) }))
319
+ .filter((x) => x.team && x.rule && x.rule.agentCommits)
320
+ .map((x) => ({ teamId: x.team.id, name: x.team.name, agentCommits: x.rule.agentCommits }))
321
+ },
322
+ async orgGithub (orgId) { const o = orgGithub.get(orgId); return { requireGithub: !!(o && o.requireGithub), agentCommits: (o && o.agentCommits) || '' } },
323
+ async setOrgGithub (orgId, { requireGithub, agentCommits }) {
324
+ const o = orgGithub.get(orgId) || { requireGithub: false, agentCommits: '' }
325
+ orgGithub.set(orgId, { requireGithub: requireGithub ?? o.requireGithub, agentCommits: agentCommits ?? o.agentCommits ?? '', updatedAt: now() })
326
+ },
327
+ // The API's own settings (app_settings): a value it keeps across starts.
328
+ async getSetting (key) { return settings.has(key) ? settings.get(key) : null },
329
+ // One live session per repository and branch (git_sync_leases; mirrors the SQL functions).
330
+ async takeLease ({ repo, branch, room, holder = '', ttlMs }) {
331
+ const k = `${repo}\0${branch}`
332
+ const cur = leases.get(k)
333
+ const t = now()
334
+ if (!cur) { leases.set(k, { room, holder, generation: 1, heartbeatAt: t }); return { ok: true, generation: 1, room } }
335
+ const lapsed = cur.heartbeatAt < t - ttlMs
336
+ if (cur.room === room && !lapsed) { cur.heartbeatAt = t; cur.holder = holder; return { ok: true, generation: cur.generation, room } }
337
+ if (!lapsed) return { ok: false, room: cur.room, generation: cur.generation }
338
+ leases.set(k, { room, holder, generation: cur.generation + 1, heartbeatAt: t })
339
+ return { ok: true, generation: cur.generation + 1, room }
340
+ },
341
+ async heartbeatLease ({ repo, branch, room, generation, ttlMs }) {
342
+ const cur = leases.get(`${repo}\0${branch}`)
343
+ if (!cur || cur.room !== room || cur.generation !== generation) return { ok: false, lost: true, room: cur ? cur.room : '' }
344
+ if (cur.heartbeatAt < now() - ttlMs) return { ok: false, expired: true }
345
+ cur.heartbeatAt = now()
346
+ return { ok: true, generation }
347
+ },
348
+ async releaseLease ({ repo, branch, room, generation }) {
349
+ const k = `${repo}\0${branch}`
350
+ const cur = leases.get(k)
351
+ if (cur && cur.room === room && cur.generation === generation) leases.delete(k)
352
+ return { ok: true }
353
+ },
354
+ async setSetting (key, value) { settings.set(key, String(value)) },
355
+ // Stores `value` unless the key has one already; returns what is stored.
356
+ async setSettingIfAbsent (key, value) { if (!settings.has(key)) settings.set(key, String(value)); return settings.get(key) },
357
+
358
+ // Relay machines and where each room lives (relay_machines, relay_rooms; src/api/relay-cluster.js).
359
+ async relayMachine (id) { return copy(relayMachines.get(id)) },
360
+ async relayMachines () { return [...relayMachines.values()].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map(copy) },
361
+ async putRelayMachine ({ id, ...fields }) {
362
+ const cur = relayMachines.get(id) || { id, region: '', capacity: 50, state: 'active', dedicatedRoom: null, load: {}, helloAt: null, reportedAt: null, createdAt: now() }
363
+ for (const [k, v] of Object.entries(fields)) if (v !== undefined) cur[k] = structuredClone(v)
364
+ if (cur.dedicatedRoom) for (const m of relayMachines.values()) if (m.id !== id && m.dedicatedRoom === cur.dedicatedRoom) throw Object.assign(new Error('duplicate key value violates unique constraint relay_machines_dedicated_room_key'), { code: '23505' })
365
+ relayMachines.set(id, cur)
366
+ return copy(cur)
367
+ },
368
+ async relayRoom (room) { return copy(relayRooms.get(room)) },
369
+ async relayRoomsOn (machineId) { return all(relayRooms, (r) => r.machineId === machineId).map(copy) },
370
+ async relayRoomCounts () {
371
+ const out = {}
372
+ for (const r of relayRooms.values()) out[r.machineId] = (out[r.machineId] || 0) + 1
373
+ return out
374
+ },
375
+ // Mirrors place_relay_room: placed once; the room stays where it was put.
376
+ async placeRelayRoom (room, machineId) {
377
+ if (!relayMachines.has(machineId)) throw fkViolation('relay machine', 'does not exist')
378
+ if (!relayRooms.has(room)) relayRooms.set(room, { room, machineId, state: 'placed', stats: {}, busyMinutes: 0, heavy: [], placedAt: now(), reportedAt: null })
379
+ return copy(relayRooms.get(room))
380
+ },
381
+ async setRelayRoom (room, patch) {
382
+ const r = relayRooms.get(room)
383
+ if (!r) return null
384
+ if (patch.machineId !== undefined && !relayMachines.has(patch.machineId)) throw fkViolation('relay machine', 'does not exist')
385
+ for (const [k, v] of Object.entries(patch)) if (v !== undefined) r[k] = structuredClone(v)
386
+ return copy(r)
387
+ },
388
+ async forgetRelayRoom (room, machineId) {
389
+ const r = relayRooms.get(room)
390
+ if (r && (!machineId || r.machineId === machineId)) relayRooms.delete(room)
391
+ },
392
+ // Mirrors take_relay_job.
393
+ async takeRelayJob (key, { detail = {}, staleMs, retryMs }) {
394
+ const cur = relayJobs.get(key)
395
+ const t = now()
396
+ const free = !cur || (cur.state === 'running' && cur.startedAt < t - staleMs) || (cur.state !== 'running' && (cur.finishedAt ?? cur.startedAt) < t - retryMs)
397
+ if (!free) return false
398
+ relayJobs.set(key, { key, state: 'running', detail: copy(detail), startedAt: t, finishedAt: null })
399
+ return true
400
+ },
401
+ async finishRelayJob (key, { state, detail }) {
402
+ const cur = relayJobs.get(key)
403
+ if (cur) Object.assign(cur, { state, finishedAt: now(), ...(detail ? { detail: copy(detail) } : {}) })
404
+ },
405
+ async relayJob (key) { return copy(relayJobs.get(key)) },
406
+ // Hosted agents across relay machines (relay_hosted_rooms, relay_hosted_webhooks).
407
+ async hostedRoomsOf (account) { return all(hostedRooms, (h) => h.account === account).sort((a, b) => a.addedAt - b.addedAt || a.room.localeCompare(b.room)).map((h) => h.room) },
408
+ async addHostedRoom (account, room) { const k = `${account}\n${room}`; if (!hostedRooms.has(k)) hostedRooms.set(k, { account, room, addedAt: now() }) },
409
+ async removeHostedRoom (account, room) { hostedRooms.delete(`${account}\n${room}`) },
410
+ async hostedWebhookOf (account) { return copy(hostedWebhooks.get(account)?.webhook || null) },
411
+ async putHostedWebhook (account, webhook) { if (webhook) hostedWebhooks.set(account, { webhook: copy(webhook), updatedAt: now() }); else hostedWebhooks.delete(account) },
412
+ // Whether `account` owns session `room` or has been in it (relay_sessions, session_visits).
413
+ async roomHasAccount (room, account) {
414
+ const s = relaySessions.get(room)
415
+ if (s && s.ownerAccount === account) return true
416
+ return all(visits, (v) => v.room === room && v.account === account).length > 0
417
+ },
418
+ // A resume signature (its sha256, hex): true the first time in `windowMs`, false after.
419
+ async spendResumeSignature (hash, windowMs) {
420
+ const t = now()
421
+ for (const [h, at] of resumeSignatures) if (at < t - windowMs) resumeSignatures.delete(h)
422
+ if (resumeSignatures.has(hash)) return false
423
+ resumeSignatures.set(hash, t)
424
+ return true
425
+ },
426
+
427
+ // Session activity, as the relay reports it (routes/relay.js). Mirrors ingest_presence:
428
+ // events apply in order, each once; returns how many were new.
429
+ async ingestPresence (events, receivedAt) {
430
+ let applied = 0
431
+ for (const e of events) {
432
+ if (seenEvents.has(e.id)) continue
433
+ seenEvents.set(e.id, receivedAt)
434
+ applied++
435
+ const s = relaySessions.get(e.room)
436
+ if (e.type === 'start') {
437
+ if (!s) relaySessions.set(e.room, { room: e.room, name: '', ownerAccount: e.owner ? e.account : null, createdAt: e.at, lastActiveAt: e.at, renamedAt: null, workspaceId: null, workspaceLinkedBy: null })
438
+ else {
439
+ if (!s.ownerAccount && e.owner) s.ownerAccount = e.account
440
+ s.lastActiveAt = Math.max(s.lastActiveAt, e.at)
441
+ }
442
+ if (!all(visits, (v) => v.eventStartId === e.id).length) {
443
+ const v = { id: uuid(), eventStartId: e.id, room: e.room, account: e.account, accountName: e.name || '', kind: e.account.split(':')[0], startedAt: e.at, endedAt: null, via: e.via || null, tool: e.tool || null, endReason: null }
444
+ visits.set(v.id, v)
445
+ }
446
+ } else if (e.type === 'end') {
447
+ for (const v of visits.values()) if (v.eventStartId === e.start && v.endedAt == null) Object.assign(v, { endedAt: Math.max(v.startedAt, e.at), endReason: e.reason || null })
448
+ if (s) s.lastActiveAt = Math.max(s.lastActiveAt, e.at)
449
+ } else if (e.type === 'act') {
450
+ // An act whose visit never arrived (its start was dropped from a full queue) is skipped.
451
+ const v = all(visits, (x) => x.eventStartId === e.start)[0]
452
+ if (v && !actions.has(e.id)) actions.set(e.id, { id: e.id, visitStartId: v.eventStartId, room: v.room, account: v.account, action: e.action, target: e.target || '', at: e.at })
453
+ } else if (e.type === 'name') {
454
+ if (!s) relaySessions.set(e.room, { room: e.room, name: e.name, ownerAccount: null, createdAt: e.at, lastActiveAt: e.at, renamedAt: null, workspaceId: null, workspaceLinkedBy: null })
455
+ else if (s.renamedAt == null) s.name = e.name
456
+ }
457
+ }
458
+ return applied
459
+ },
460
+ // The sessions an account was in: the `limit` most recently active, plus any active since `since`.
461
+ async accountSessions (account, { since, limit }) {
462
+ const rooms = new Set(all(visits, (v) => v.account === account).map((v) => v.room))
463
+ const list = all(relaySessions, (s) => rooms.has(s.room)).sort((a, b) => b.lastActiveAt - a.lastActiveAt || a.room.localeCompare(b.room))
464
+ const keep = new Set(list.slice(0, limit))
465
+ return list.filter((s) => keep.has(s) || s.lastActiveAt >= since).map(copy)
466
+ },
467
+ async sessionByRoom (room) { return copy(relaySessions.get(room)) },
468
+ async visitsInRooms (rooms) {
469
+ const set = new Set(rooms)
470
+ return all(visits, (v) => set.has(v.room)).sort((a, b) => a.startedAt - b.startedAt).map(copy)
471
+ },
472
+ // The owner's rename: from now on the relay's name events leave it alone.
473
+ async renameSession (room, name, at) {
474
+ const s = relaySessions.get(room)
475
+ if (!s) return null
476
+ Object.assign(s, { name, renamedAt: at })
477
+ return copy(s)
478
+ },
479
+ // Mirrors prune_activity.
480
+ // What was done in a session in [from, to), oldest first, at most `limit` (actions_in_room).
481
+ async actionsInRoom (room, { from, to, limit }) {
482
+ return all(actions, (a) => a.room === room && a.at >= from && a.at < to)
483
+ .sort((a, b) => a.at - b.at).slice(0, limit).map(copy) // stable: ties keep arrival order
484
+ },
485
+ async pruneActivity ({ before, seenBefore }) {
486
+ for (const [id, v] of visits) if (v.endedAt != null && v.endedAt < before) visits.delete(id)
487
+ for (const [room, s] of relaySessions) if (s.lastActiveAt < before && !all(visits, (v) => v.room === room).length) relaySessions.delete(room)
488
+ for (const [id, at] of seenEvents) if (at < seenBefore) seenEvents.delete(id)
489
+ dropOrphans()
490
+ },
491
+
492
+ // Access types (see 20261002010000_access_types_and_invites.sql). The built-ins live in
493
+ // session-access.js, not here.
494
+ async listAccessTypes (ownerAccount) {
495
+ return all(accessTypes, (t) => t.ownerAccount === ownerAccount).sort((a, b) => a.createdAt - b.createdAt).map(copy)
496
+ },
497
+ async accessTypeById (id) { return copy(accessTypes.get(id)) },
498
+ async createAccessType ({ ownerAccount, name, files, folders = [], talk = true }) {
499
+ if (folders.length > 20) throw checkViolation('at most 20 folders')
500
+ const row = { id: uuid(), ownerAccount, name, files, folders: [...folders], talk, createdAt: now(), updatedAt: now() }
501
+ accessTypes.set(row.id, row); return copy(row)
502
+ },
503
+ async updateAccessType (id, patch) {
504
+ const t = accessTypes.get(id)
505
+ if (!t) return null
506
+ for (const k of ['name', 'files', 'folders', 'talk']) if (patch[k] !== undefined) t[k] = copy(patch[k])
507
+ t.updatedAt = now()
508
+ return copy(t)
509
+ },
510
+ // Mirrors delete_access_type: grants and invites that used it fall back to View only.
511
+ async deleteAccessType (id, ownerAccount) {
512
+ const t = accessTypes.get(id)
513
+ if (!t || t.ownerAccount !== ownerAccount) return false
514
+ accessTypes.delete(id)
515
+ for (const g of grants.values()) if (g.typeId === id) Object.assign(g, { typeId: FALLBACK_TYPE, updatedAt: now() })
516
+ for (const i of sessionInvites.values()) if (i.typeId === id) i.typeId = FALLBACK_TYPE
517
+ return true
518
+ },
519
+
520
+ // Grants: one per (room, account). Mirrors the foreign key to relay_sessions.
521
+ async grantFor (room, account) { return copy(grants.get(grantKey(room, account))) },
522
+ async listGrants (room) { return all(grants, (g) => g.room === room).sort((a, b) => a.createdAt - b.createdAt).map(copy) },
523
+ async putGrant ({ room, account, typeId, tighten = {}, grantedBy }) {
524
+ if (!relaySessions.has(room)) throw fkViolation('session', 'does not exist')
525
+ const k = grantKey(room, account)
526
+ const old = grants.get(k)
527
+ const row = { room, account, typeId, tighten: copy(tighten), grantedBy, createdAt: old ? old.createdAt : now(), updatedAt: now() }
528
+ grants.set(k, row); return copy(row)
529
+ },
530
+ async deleteGrant (room, account) { return grants.delete(grantKey(room, account)) },
531
+
532
+ // Session invites: the link is kept only for the account or address it was sent to (invitesFor).
533
+ // Mirrors the unique indexes on open invites, and clearing that key's expired ones first.
534
+ async createSessionInvite ({ room, email = null, account = null, accountName = '', typeId, invitedBy, expiresAt, link = null, at = now() }) {
535
+ if (!relaySessions.has(room)) throw fkViolation('session', 'does not exist')
536
+ if ((email == null) === (account == null)) throw checkViolation('an invite is for an email or an account')
537
+ email = email && email.toLowerCase()
538
+ const same = (i) => i.room === room && (email ? i.email === email : i.account === account) && !i.usedAt && !i.cancelledAt
539
+ for (const [id, i] of sessionInvites) if (same(i) && i.expiresAt <= at) sessionInvites.delete(id)
540
+ if (all(sessionInvites, same).length) throw duplicate('an open invite')
541
+ const row = { id: uuid(), room, email: email && email.toLowerCase(), account, accountName, typeId, invitedBy, createdAt: now(), expiresAt, usedAt: null, usedBy: null, cancelledAt: null, link }
542
+ sessionInvites.set(row.id, row); return noLink(row)
543
+ },
544
+ async listSessionInvites (room) {
545
+ return all(sessionInvites, (i) => i.room === room).sort((a, b) => b.createdAt - a.createdAt).slice(0, 50).map(noLink)
546
+ },
547
+ async sessionInviteById (room, id) { const i = sessionInvites.get(id); return i && i.room === room ? noLink(i) : null },
548
+ // The open session invites sent to an account or a (confirmed) address, with their links.
549
+ async sessionInvitesFor ({ account = null, email = null }, at) {
550
+ return all(sessionInvites, (i) => ((account && i.account === account) || (email && i.email === email)) && inviteOpenAt(i, at))
551
+ .sort((a, b) => b.createdAt - a.createdAt).slice(0, 50).map((i) => ({ ...copy(i), sessionName: relaySessions.get(i.room)?.name || '' }))
552
+ },
553
+ // A session invite, only when it was sent to this account or address.
554
+ async sessionInviteForMe (id, { account = null, email = null }) {
555
+ const i = sessionInvites.get(id)
556
+ return i && ((account && i.account === account) || (email && i.email === email)) ? noLink(i) : null
557
+ },
558
+ async openSessionInvite (room, { email = null, account = null }, at, exceptId = null) {
559
+ const found = all(sessionInvites, (i) => i.room === room && i.id !== exceptId && (email ? i.email === email : i.account === account) && inviteOpenAt(i, at))
560
+ return found[0] ? noLink(found[0]) : null
561
+ },
562
+ // Check-and-set: only a waiting invite is cancelled.
563
+ async cancelSessionInvite (id) {
564
+ const i = sessionInvites.get(id)
565
+ if (!i || i.usedAt || i.cancelledAt) return false
566
+ i.cancelledAt = now(); return true
567
+ },
568
+ // Mirrors delete_unused_grant.
569
+ async deleteUnusedGrant (room, account, at) {
570
+ const email = account.startsWith('email:') ? account.slice('email:'.length) : null
571
+ if (all(sessionInvites, (i) => i.room === room && (email ? i.email === email : i.account === account) && inviteOpenAt(i, at)).length) return false
572
+ return grants.delete(grantKey(room, account))
573
+ },
574
+ // Mirrors claim_email_invites: the open invites are used, and the email's grant becomes the account's.
575
+ async claimEmailInvites (room, email, account) {
576
+ const at = now()
577
+ const open = all(sessionInvites, (i) => i.room === room && i.email === email && inviteOpenAt(i, at))
578
+ if (!open.length) return 0
579
+ for (const i of open) Object.assign(i, { usedAt: at, usedBy: account })
580
+ const from = grants.get(grantKey(room, `email:${email}`))
581
+ if (from) {
582
+ const old = grants.get(grantKey(room, account))
583
+ grants.set(grantKey(room, account), { ...copy(from), account, createdAt: old ? old.createdAt : at, updatedAt: at })
584
+ grants.delete(grantKey(room, `email:${email}`))
585
+ }
586
+ return open.length
587
+ },
588
+ // An invited account came in: its open invites are used.
589
+ async useAccountInvites (room, account) {
590
+ const at = now()
591
+ for (const i of sessionInvites.values()) if (i.room === room && i.account === account && inviteOpenAt(i, at)) Object.assign(i, { usedAt: at, usedBy: account })
592
+ },
593
+
594
+ // Orgs. Creating one makes its three built-in roles and its owner together.
595
+ // first: true mirrors create_org's advisory lock for "a team" sign-ups: if
596
+ // the owner is already in an org (this call or another one that won the
597
+ // race), that org comes back instead of a second one.
598
+ async createOrg ({ name, slug, ownerId, grants, first = false }) {
599
+ if (first) {
600
+ const already = all(members, (m) => m.userId === ownerId)[0]
601
+ if (already) return copy(orgs.get(already.orgId))
602
+ }
603
+ if (all(orgs, (o) => o.slug === slug).length) throw duplicate('org')
604
+ const org = { id: uuid(), name, slug, ownerId, domain: null, domainRequests: false, createdAt: now() }
605
+ orgs.set(org.id, org)
606
+ let ownerRole
607
+ for (const [builtin, roleName] of [['owner', 'Owner'], ['admin', 'Admin'], ['member', 'Member']]) {
608
+ const r = { id: uuid(), orgId: org.id, name: roleName, builtin, grants: copy(grants[builtin]), createdAt: now() }
609
+ roles.set(r.id, r)
610
+ if (builtin === 'owner') ownerRole = r
611
+ }
612
+ const m = { id: uuid(), orgId: org.id, userId: ownerId, agentId: null, roleId: ownerRole.id, joinedAt: now() }
613
+ members.set(m.id, m)
614
+ return copy(org)
615
+ },
616
+ async orgBySlug (slug) { return copy(all(orgs, (o) => o.slug === slug)[0]) },
617
+ async orgById (id) { return copy(orgs.get(id)) },
618
+ async orgsForUser (userId) {
619
+ return all(members, (m) => m.userId === userId)
620
+ .map((m) => ({ ...copy(orgs.get(m.orgId)), roleId: m.roleId }))
621
+ .sort((a, b) => a.name.localeCompare(b.name))
622
+ },
623
+ async orgsByDomain (domain) { return all(orgs, (o) => o.domain === domain && o.domainRequests).map(copy) },
624
+ async updateOrg (id, patch) {
625
+ const o = orgs.get(id)
626
+ for (const k of ['name', 'domain', 'domainRequests']) if (patch[k] !== undefined) o[k] = patch[k]
627
+ return copy(o)
628
+ },
629
+ async deleteOrg (id) {
630
+ orgs.delete(id)
631
+ orgGithub.delete(id)
632
+ for (const [k, c] of githubConnections) if (c.scope === 'org' && c.scopeId === id) githubConnections.delete(k)
633
+ for (const [k, a] of agents) if (a.orgId === id) dropAgent(k)
634
+ for (const [k, i] of agentInvites) if (i.orgId === id) agentInvites.delete(k)
635
+ for (const [k, m] of members) if (m.orgId === id) dropMember(k)
636
+ for (const [k, t] of teams) if (t.orgId === id) teams.delete(k)
637
+ for (const [k, r] of roles) if (r.orgId === id) roles.delete(k)
638
+ for (const [k, i] of invites) if (i.orgId === id) invites.delete(k)
639
+ for (const [k, r] of requests) if (r.orgId === id) requests.delete(k)
640
+ for (const [k, w] of workspaces) if (w.orgId === id) { for (const s of relaySessions.values()) if (s.workspaceId === k) s.workspaceId = null; for (const [mk, m] of workspaceMembers) if (m.workspaceId === k) workspaceMembers.delete(mk); workspaces.delete(k) }
641
+ },
642
+ // Ownership moves in one step: the old owner becomes an Admin. Mirrors
643
+ // transfer_org's own errcodes (QO003/QO002/QO001) for the same checks.
644
+ async transferOrg (orgId, fromUserId, toUserId) {
645
+ const o = orgs.get(orgId)
646
+ // fromUserId is the owner the caller saw when they clicked transfer; if
647
+ // ownership already moved, refuse instead of transferring it a second time.
648
+ if (o.ownerId !== fromUserId) throw Object.assign(new Error('not the owner'), { code: 'QO003' })
649
+ const to = findMember(orgId, toUserId)
650
+ if (!to) throw Object.assign(new Error('target is not a member of this org'), { code: 'QO002' })
651
+ const from = findMember(orgId, o.ownerId)
652
+ if (!from) throw Object.assign(new Error('current owner is not a member of this org'), { code: 'QO001' })
653
+ const builtin = (b) => all(roles, (r) => r.orgId === orgId && r.builtin === b)[0]
654
+ from.roleId = builtin('admin').id
655
+ to.roleId = builtin('owner').id
656
+ o.ownerId = toUserId
657
+ },
658
+
659
+ // Roles.
660
+ async listRoles (orgId) { return all(roles, (r) => r.orgId === orgId).map(copy) },
661
+ async roleById (orgId, id) { const r = roles.get(id); return r && r.orgId === orgId ? copy(r) : null },
662
+ async createRole ({ orgId, name, grants }) {
663
+ if (all(roles, (r) => r.orgId === orgId && r.name === name).length) throw duplicate('role')
664
+ const r = { id: uuid(), orgId, name, builtin: null, grants: copy(grants), createdAt: now() }
665
+ roles.set(r.id, r); return copy(r)
666
+ },
667
+ async updateRole (id, { name, grants }) {
668
+ const r = roles.get(id)
669
+ if (name !== undefined && all(roles, (x) => x.orgId === r.orgId && x.name === name && x.id !== id).length) throw duplicate('role')
670
+ if (name !== undefined) r.name = name
671
+ if (grants !== undefined) r.grants = copy(grants)
672
+ return copy(r)
673
+ },
674
+ // A deleted role's closed invites (accepted, cancelled or expired) go with
675
+ // it; an open invite still referencing the role blocks the delete, mirroring
676
+ // the (role_id, org_id) foreign key's NO ACTION in Postgres.
677
+ async deleteRole (id) {
678
+ for (const [k, i] of invites) {
679
+ if (i.roleId === id && !inviteOpen(i)) invites.delete(k)
680
+ }
681
+ if (all(invites, (i) => i.roleId === id).length) throw fkViolation('role')
682
+ // agent_invites' role key is "on delete set null (role_id)": the invite keeps going without a role.
683
+ for (const i of agentInvites.values()) if (i.roleId === id) i.roleId = null
684
+ roles.delete(id)
685
+ },
686
+ // In use: someone holds it, or an open invite would hand it out.
687
+ async roleInUse (id) {
688
+ return all(members, (m) => m.roleId === id).length > 0 ||
689
+ all(invites, (i) => i.roleId === id && inviteOpen(i)).length > 0
690
+ },
691
+ // Members.
692
+ async memberOf (orgId, userId) { return copy(findMember(orgId, userId)) },
693
+ async memberById (orgId, id) { const m = members.get(id); return m && m.orgId === orgId ? copy(m) : null },
694
+ async listMembers (orgId) {
695
+ return all(members, (m) => m.orgId === orgId)
696
+ .map((m) => {
697
+ const a = m.agentId ? agents.get(m.agentId) : null
698
+ return { ...copy(m), name: memberName(m), provider: a?.provider ?? null, type: a?.type ?? null, publicKey: a?.publicKey ?? null }
699
+ })
700
+ .sort((a, b) => a.joinedAt - b.joinedAt)
701
+ },
702
+ async addMember ({ orgId, userId, roleId }) {
703
+ const existing = findMember(orgId, userId)
704
+ if (existing) return copy(existing)
705
+ if (!roleInOrg(roleId, orgId)) throw fkViolation('role')
706
+ const m = { id: uuid(), orgId, userId, agentId: null, roleId, joinedAt: now() }
707
+ members.set(m.id, m); return copy(m)
708
+ },
709
+ async setMemberRole (id, roleId) {
710
+ const m = members.get(id)
711
+ if (!roleInOrg(roleId, m.orgId)) throw fkViolation('role')
712
+ m.roleId = roleId; return copy(m)
713
+ },
714
+ async removeMember (id) { dropMember(id) },
715
+ // Mirrors the composite (agent_id, org_id) foreign key: only the org's own agents join it.
716
+ async addAgentMember ({ orgId, agentId, roleId = null }) {
717
+ if (agents.get(agentId)?.orgId !== orgId) throw fkViolation('agent', 'is not in this org')
718
+ if (!roleInOrg(roleId, orgId)) throw fkViolation('role')
719
+ if (all(members, (m) => m.orgId === orgId && m.agentId === agentId).length) throw duplicate('member')
720
+ const m = { id: uuid(), orgId, userId: null, agentId, roleId, joinedAt: now() }
721
+ members.set(m.id, m); return copy(m)
722
+ },
723
+ async memberByAgent (orgId, agentId) { return copy(all(members, (m) => m.orgId === orgId && m.agentId === agentId)[0]) },
724
+
725
+ // Teams.
726
+ async listTeams (orgId) { return all(teams, (t) => t.orgId === orgId).sort((a, b) => a.name.localeCompare(b.name)).map(copy) },
727
+ async teamById (orgId, id) { const t = teams.get(id); return t && t.orgId === orgId ? copy(t) : null },
728
+ async createTeam ({ orgId, name }) {
729
+ if (all(teams, (t) => t.orgId === orgId && t.name === name).length) throw duplicate('team')
730
+ const t = { id: uuid(), orgId, name, createdAt: now() }
731
+ teams.set(t.id, t); return copy(t)
732
+ },
733
+ async renameTeam (id, name) {
734
+ const t = teams.get(id)
735
+ if (all(teams, (x) => x.orgId === t.orgId && x.name === name && x.id !== id).length) throw duplicate('team')
736
+ t.name = name; return copy(t)
737
+ },
738
+ async deleteTeam (id) {
739
+ teams.delete(id)
740
+ teamGithub.delete(id)
741
+ for (const [k, tm] of teamMembers) if (tm.teamId === id) teamMembers.delete(k)
742
+ },
743
+ async listTeamMembers (teamId) {
744
+ return all(teamMembers, (tm) => tm.teamId === teamId).sort((a, b) => a.addedAt - b.addedAt)
745
+ .map((tm) => {
746
+ const m = members.get(tm.memberId)
747
+ return { ...copy(tm), name: memberName(m), kind: m?.agentId ? 'agent' : 'person' }
748
+ })
749
+ },
750
+ async teamsOfMember (memberId) {
751
+ return all(teamMembers, (tm) => tm.memberId === memberId).map((tm) => ({ teamId: tm.teamId, access: tm.access, scopes: [...tm.scopes] }))
752
+ },
753
+ // Mirrors the composite (team_id, org_id) / (member_id, org_id) foreign
754
+ // keys: a team and the member it holds must be in the same org.
755
+ async addTeamMember ({ teamId, memberId, access, scopes = [] }) {
756
+ const team = teams.get(teamId)
757
+ const member = members.get(memberId)
758
+ if (!team || !member || team.orgId !== member.orgId) throw fkViolation('team or member', 'is not in this org')
759
+ tooManyFolders(scopes)
760
+ const key = `${teamId}:${memberId}`
761
+ const tm = teamMembers.get(key) || { teamId, memberId, addedAt: now() }
762
+ tm.access = access
763
+ tm.scopes = [...scopes]
764
+ teamMembers.set(key, tm); return copy(tm)
765
+ },
766
+ // Folders only change when given, so an access-only change keeps them.
767
+ async setTeamAccess (teamId, memberId, access, scopes) {
768
+ const tm = teamMembers.get(`${teamId}:${memberId}`)
769
+ if (!tm) return null
770
+ if (scopes !== undefined) tooManyFolders(scopes)
771
+ tm.access = access
772
+ if (scopes !== undefined) tm.scopes = [...scopes]
773
+ return copy(tm)
774
+ },
775
+ async removeTeamMember (teamId, memberId) { return teamMembers.delete(`${teamId}:${memberId}`) },
776
+
777
+ // Invites: only the token's hash is kept. Mirrors org_invites.email's
778
+ // lowercase check constraint.
779
+ async createInvite (i) {
780
+ const row = { id: uuid(), acceptedAt: null, cancelledAt: null, createdAt: now(), ...i, email: i.email.toLowerCase() }
781
+ invites.set(row.id, row); return copy(row)
782
+ },
783
+ async inviteByToken (h) { return copy(all(invites, (i) => i.tokenHash === h)[0]) },
784
+ async inviteById (orgId, id) { const i = invites.get(id); return i && i.orgId === orgId ? copy(i) : null },
785
+ async listInvites (orgId) { return all(invites, (i) => i.orgId === orgId && !i.acceptedAt && !i.cancelledAt).map(copy) },
786
+ async updateInvite (id, patch) { const i = invites.get(id); Object.assign(i, patch); return copy(i) },
787
+ // Check-and-set, so one invite can't be accepted twice, after it was
788
+ // cancelled, or once it's expired — the same "open invite" definition inviteOpen uses.
789
+ async claimInvite (id) {
790
+ const i = invites.get(id)
791
+ if (!i || !inviteOpen(i)) return false
792
+ i.acceptedAt = now(); return true
793
+ },
794
+
795
+ // Domain join requests: at most one pending per person per org. Mirrors
796
+ // join_requests.email's lowercase check constraint.
797
+ async createJoinRequest ({ orgId, userId, email }) {
798
+ const pending = all(requests, (r) => r.orgId === orgId && r.userId === userId && r.status === 'pending')[0]
799
+ if (pending) return copy(pending)
800
+ const r = { id: uuid(), orgId, userId, email: email.toLowerCase(), status: 'pending', decidedBy: null, decidedAt: null, createdAt: now() }
801
+ requests.set(r.id, r); return copy(r)
802
+ },
803
+ async joinRequestById (orgId, id) { const r = requests.get(id); return r && r.orgId === orgId ? copy(r) : null },
804
+ async listJoinRequests (orgId) {
805
+ return all(requests, (r) => r.orgId === orgId && r.status === 'pending').map((r) => ({ ...copy(r), name: nameOf(r.userId) }))
806
+ },
807
+ async joinRequestsForUser (userId) { return all(requests, (r) => r.userId === userId).map(copy) },
808
+ // Check-and-set: a request is decided once.
809
+ async decideJoinRequest (id, { status, decidedBy }) {
810
+ const r = requests.get(id)
811
+ if (!r || r.status !== 'pending') return false
812
+ Object.assign(r, { status, decidedBy, decidedAt: now() }); return true
813
+ },
814
+ // Issue reports. One event per outcome; a failure also opens (or counts up) its
815
+ // issue by fingerprint. Mirrors record_events(jsonb) in Postgres.
816
+ async recordEvents (list) {
817
+ for (const e of list) {
818
+ let issueId = null
819
+ if (e.outcome !== 'ok') {
820
+ let issue = issues.get(e.fingerprint)
821
+ if (!issue) {
822
+ issue = { id: uuid(), fingerprint: e.fingerprint, surface: e.surface, kind: e.kind, name: e.name, message: e.message, count: 0, firstSeenAt: e.occurredAt, lastSeenAt: e.occurredAt, resolvedAt: null }
823
+ issues.set(e.fingerprint, issue)
824
+ }
825
+ issue.count += 1
826
+ issue.lastSeenAt = Math.max(issue.lastSeenAt, e.occurredAt)
827
+ issue.resolvedAt = null
828
+ issueId = issue.id
829
+ }
830
+ const row = { id: uuid(), ...copy(e), issueId, createdAt: now() }
831
+ delete row.fingerprint
832
+ events.set(row.id, row)
833
+ }
834
+ return list.length
835
+ },
836
+ async pruneEvents (before) {
837
+ let n = 0
838
+ for (const [id, e] of events) if (e.occurredAt < before) { events.delete(id); n++ }
839
+ return n
840
+ },
841
+ // Issues people haven't seen in a long while, so the one table meant to be read
842
+ // doesn't grow forever (scanners hitting unknown routes, abandoned reports, …).
843
+ async pruneIssues (before) {
844
+ let n = 0
845
+ for (const [key, i] of issues) if (i.lastSeenAt < before) { issues.delete(key); n++ }
846
+ return n
847
+ },
848
+ // Workspaces (see 20261004000000_workspaces.sql).
849
+ async createWorkspace ({ ownerUserId = null, orgId = null, name, description = '', color = '', createdBy }) {
850
+ if ((ownerUserId == null) === (orgId == null)) throw checkViolation('a workspace belongs to a person or an org')
851
+ const row = { id: uuid(), ownerUserId, orgId, name, description, color, createdBy, createdAt: now(), archivedAt: null, quotaBytes: 5368709120, usedBytes: 0, fileCount: 0 }
852
+ workspaces.set(row.id, row); return copy(row)
853
+ },
854
+ async workspaceById (id) { return copy(workspaces.get(id)) },
855
+ async listWorkspacesOwnedBy (userId) { return all(workspaces, (w) => w.ownerUserId === userId).sort((a, b) => a.name.localeCompare(b.name)).map(copy) },
856
+ async listWorkspacesOfOrg (orgId) { return all(workspaces, (w) => w.orgId === orgId).sort((a, b) => a.name.localeCompare(b.name)).map(copy) },
857
+ async listWorkspacesForMember (account) {
858
+ return all(workspaceMembers, (m) => m.account === account)
859
+ .map((m) => ({ ...copy(workspaces.get(m.workspaceId)), memberAccess: m.access }))
860
+ .filter((w) => w.id)
861
+ .sort((a, b) => a.name.localeCompare(b.name))
862
+ },
863
+ async updateWorkspace (id, patch) {
864
+ const w = workspaces.get(id)
865
+ if (!w) return null
866
+ for (const k of ['name', 'description', 'color', 'archivedAt']) if (patch[k] !== undefined) w[k] = patch[k]
867
+ return copy(w)
868
+ },
869
+ async deleteWorkspace (id) {
870
+ workspaceGithub.delete(id)
871
+ for (const [k, c] of githubConnections) if (c.scope === 'workspace' && c.scopeId === id) githubConnections.delete(k)
872
+ for (const s of relaySessions.values()) if (s.workspaceId === id) s.workspaceId = null
873
+ for (const [k, m] of workspaceMembers) if (m.workspaceId === id) workspaceMembers.delete(k)
874
+ for (const [k, i] of workspaceInvites) if (i.workspaceId === id) workspaceInvites.delete(k)
875
+ for (const [k, f] of workspaceFiles) {
876
+ if (f.workspaceId !== id) continue
877
+ for (const vk of [...workspaceFileVersions.keys()]) if (vk.startsWith(`${f.id}\n`)) workspaceFileVersions.delete(vk)
878
+ workspaceFiles.delete(k)
879
+ }
880
+ for (const [k, o] of workspaceAgentOverrides) if (o.workspaceId === id) workspaceAgentOverrides.delete(k)
881
+ // As Postgres cascades agent_invites.workspace_id: a link for this workspace goes with it.
882
+ for (const [k, i] of agentInvites) if (i.workspaceId === id) agentInvites.delete(k)
883
+ workspaces.delete(id)
884
+ },
885
+ async workspaceMember (workspaceId, account) { return copy(workspaceMembers.get(wmKey(workspaceId, account))) },
886
+ async listWorkspaceMembers (workspaceId) { return all(workspaceMembers, (m) => m.workspaceId === workspaceId).sort((a, b) => a.addedAt - b.addedAt).map(copy) },
887
+ // sessions only changes when given: an upsert that omits it keeps whatever the
888
+ // member already had, and a brand new member defaults to 'invited'.
889
+ async putWorkspaceMember ({ workspaceId, account, access, addedBy, sessions }) {
890
+ if (!workspaces.has(workspaceId)) throw fkViolation('workspace', 'does not exist')
891
+ const k = wmKey(workspaceId, account)
892
+ const old = workspaceMembers.get(k)
893
+ const row = { workspaceId, account, access, addedBy, sessions: sessions !== undefined ? sessions : (old ? old.sessions : 'invited'), addedAt: old ? old.addedAt : now() }
894
+ workspaceMembers.set(k, row); return copy(row)
895
+ },
896
+ async removeWorkspaceMember (workspaceId, account) { return workspaceMembers.delete(wmKey(workspaceId, account)) },
897
+
898
+ // Workspace invites (20261009000000_invite_inbox.sql): one open per address or account,
899
+ // that key's expired ones cleared first. Answered once: accepted, declined or cancelled.
900
+ async createWorkspaceInvite ({ workspaceId, email = null, account = null, accountName = '', access, invitedBy, expiresAt, at = now() }) {
901
+ if (!workspaces.has(workspaceId)) throw fkViolation('workspace', 'does not exist')
902
+ if ((email == null) === (account == null)) throw checkViolation('an invite is for an email or an account')
903
+ email = email && email.toLowerCase()
904
+ const same = (i) => i.workspaceId === workspaceId && (email ? i.email === email : i.account === account) && wiWaiting(i)
905
+ for (const [id, i] of workspaceInvites) if (same(i) && i.expiresAt <= at) workspaceInvites.delete(id)
906
+ if (all(workspaceInvites, same).length) throw duplicate('an open invite')
907
+ const row = { id: uuid(), workspaceId, email, account, accountName, access, invitedBy, createdAt: now(), expiresAt, acceptedAt: null, acceptedBy: null, declinedAt: null, cancelledAt: null }
908
+ workspaceInvites.set(row.id, row); return copy(row)
909
+ },
910
+ async listWorkspaceInvites (workspaceId) {
911
+ return all(workspaceInvites, (i) => i.workspaceId === workspaceId).sort((a, b) => b.createdAt - a.createdAt).slice(0, 50).map(copy)
912
+ },
913
+ async workspaceInviteById (id) { return copy(workspaceInvites.get(id)) },
914
+ async openWorkspaceInvite (workspaceId, { email = null, account = null }, at) {
915
+ return copy(all(workspaceInvites, (i) => i.workspaceId === workspaceId && (email ? i.email === email : i.account === account) && wiWaiting(i) && i.expiresAt > at)[0] || null)
916
+ },
917
+ async workspaceInvitesFor ({ account = null, email = null }, at) {
918
+ return all(workspaceInvites, (i) => ((account && i.account === account) || (email && i.email === email)) && wiWaiting(i) && i.expiresAt > at)
919
+ .sort((a, b) => b.createdAt - a.createdAt).slice(0, 50).map(copy)
920
+ },
921
+ // Check-and-set: only a waiting invite is answered. `how` is accepted, declined or cancelled.
922
+ async answerWorkspaceInvite (id, how, by = null) {
923
+ const i = workspaceInvites.get(id)
924
+ if (!i || !wiWaiting(i)) return false
925
+ i[`${how}At`] = now()
926
+ if (how === 'accepted') i.acceptedBy = by
927
+ return true
928
+ },
929
+ // Links a room to a workspace (or none), recording who linked it. The relay may not have
930
+ // reported the room yet: then the API makes the row, with no owner (only the relay sets that).
931
+ async setSessionWorkspace (room, workspaceId, { linkedBy = null, at }) {
932
+ if (workspaceId && !workspaces.has(workspaceId)) throw fkViolation('workspace', 'does not exist')
933
+ let s = relaySessions.get(room)
934
+ if (!s) { s = { room, name: '', ownerAccount: null, createdAt: at, lastActiveAt: at, renamedAt: null, workspaceId: null, workspaceLinkedBy: null }; relaySessions.set(room, s) }
935
+ s.workspaceId = workspaceId || null
936
+ s.workspaceLinkedBy = linkedBy || null
937
+ return copy(s)
938
+ },
939
+ async listWorkspaceSessions (workspaceId) { return all(relaySessions, (s) => s.workspaceId === workspaceId).sort((a, b) => b.lastActiveAt - a.lastActiveAt).map(copy) },
940
+
941
+ // Workspace agents (see 20261007000001_workspace_agents.sql). Where an agent works,
942
+ // a workspace's say over one that reaches it, per-session keep-outs, and its webhook.
943
+ async agentPlacement (agentId) { return copy(agentPlacements.get(agentId)) },
944
+ async putAgentPlacement ({ agentId, reach = 'manual', workspaceIds = [], sessions = 'invited', access = 'edit', scopes = [], updatedBy }) {
945
+ if (!agents.has(agentId)) throw fkViolation('agent', 'does not exist')
946
+ if (scopes.length > 20) throw checkViolation('at most 20 folders')
947
+ const row = { agentId, reach, workspaceIds: [...workspaceIds], sessions, access, scopes: [...scopes], updatedBy, updatedAt: now() }
948
+ agentPlacements.set(agentId, row); return copy(row)
949
+ },
950
+ async listAgentPlacements (agentIds) { return agentIds.map((id) => agentPlacements.get(id)).filter(Boolean).map(copy) },
951
+ async workspaceAgentOverride (workspaceId, agentId) { return copy(workspaceAgentOverrides.get(waoKey(workspaceId, agentId))) },
952
+ async putWorkspaceAgentOverride ({ workspaceId, agentId, sessions = null, excluded = false }) {
953
+ if (!workspaces.has(workspaceId)) throw fkViolation('workspace', 'does not exist')
954
+ if (!agents.has(agentId)) throw fkViolation('agent', 'does not exist')
955
+ const row = { workspaceId, agentId, sessions, excluded }
956
+ workspaceAgentOverrides.set(waoKey(workspaceId, agentId), row); return copy(row)
957
+ },
958
+ async deleteWorkspaceAgentOverride (workspaceId, agentId) { return workspaceAgentOverrides.delete(waoKey(workspaceId, agentId)) },
959
+ async listWorkspaceAgentOverrides (workspaceId) { return all(workspaceAgentOverrides, (o) => o.workspaceId === workspaceId).map(copy) },
960
+ async addSessionAgentExclusion ({ room, agentId, excludedBy }) {
961
+ if (!relaySessions.has(room)) throw fkViolation('session', 'does not exist')
962
+ if (!agents.has(agentId)) throw fkViolation('agent', 'does not exist')
963
+ const k = saeKey(room, agentId)
964
+ const old = sessionAgentExclusions.get(k)
965
+ const row = { room, agentId, excludedBy, createdAt: old ? old.createdAt : now() }
966
+ sessionAgentExclusions.set(k, row); return copy(row)
967
+ },
968
+ async removeSessionAgentExclusion (room, agentId) { return sessionAgentExclusions.delete(saeKey(room, agentId)) },
969
+ async sessionAgentExcluded (room, agentId) { return sessionAgentExclusions.has(saeKey(room, agentId)) },
970
+ async listSessionAgentExclusions (room) { return all(sessionAgentExclusions, (r) => r.room === room).map(copy) },
971
+ async agentWebhook (agentId) { return copy(agentWebhooks.get(agentId)) },
972
+ async putAgentWebhook ({ agentId, url, secret }) {
973
+ if (!agents.has(agentId)) throw fkViolation('agent', 'does not exist')
974
+ const old = agentWebhooks.get(agentId)
975
+ const row = { agentId, url, secret, createdAt: old ? old.createdAt : now(), updatedAt: now() }
976
+ agentWebhooks.set(agentId, row); return copy(row)
977
+ },
978
+ async deleteAgentWebhook (agentId) { return agentWebhooks.delete(agentId) },
979
+
980
+ // A person's cloud AI machine (see 20261017000000_ai_vms.sql): one per person.
981
+ async createAiVm ({ userId, tool, model = '', prompt = '', cap = 0, region = '' }) {
982
+ if (!profiles.has(userId)) throw fkViolation('person', 'does not exist')
983
+ if (aiVms.has(userId)) throw duplicate('cloud AI machine')
984
+ const row = { userId, tool, model, prompt, cap, agentId: null, inviteId: null, machineId: null, volumeId: null, region, state: 'creating', error: '', signedIn: false, usageToday: {}, lastReportAt: null, createdAt: now(), updatedAt: now() }
985
+ aiVms.set(userId, row); return copy(row)
986
+ },
987
+ async aiVmOf (userId) { return copy(aiVms.get(userId)) },
988
+ async aiVmByAgent (agentId) { return copy(all(aiVms, (v) => !!agentId && v.agentId === agentId)[0]) },
989
+ async updateAiVm (userId, patch) {
990
+ const row = aiVms.get(userId)
991
+ if (!row) return null
992
+ Object.assign(row, structuredClone(patch), { updatedAt: now() })
993
+ return copy(row)
994
+ },
995
+ async deleteAiVm (userId) { return aiVms.delete(userId) },
996
+
997
+ // Workspace files (see 20261005000000_workspace_files.sql).
998
+ async createWorkspaceFile ({ workspaceId, path, kind, size = 0, mime = '', sha256 = '', objectKey = '', note = '', uploadedBy }) {
999
+ if (!workspaces.has(workspaceId)) throw fkViolation('workspace', 'does not exist')
1000
+ if (all(workspaceFiles, (f) => f.workspaceId === workspaceId && f.path === path && !f.deletedAt).length) throw duplicate('file')
1001
+ const row = { id: uuid(), workspaceId, path, kind, size, mime, sha256, version: 1, objectKey, note, uploadedBy, uploadedAt: now(), confirmedAt: null, deletedAt: null }
1002
+ workspaceFiles.set(row.id, row); return copy(row)
1003
+ },
1004
+ async workspaceFileById (id) { return copy(workspaceFiles.get(id)) },
1005
+ async workspaceFileByPath (workspaceId, path) { return copy(all(workspaceFiles, (f) => f.workspaceId === workspaceId && f.path === path && !f.deletedAt)[0]) },
1006
+ async listWorkspaceFiles (workspaceId, { includeDeleted = false } = {}) {
1007
+ return all(workspaceFiles, (f) => f.workspaceId === workspaceId && (includeDeleted || !f.deletedAt))
1008
+ .sort((a, b) => (a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === 'folder' ? -1 : 1)).map(copy)
1009
+ },
1010
+ async newWorkspaceFileVersion (id, { size, mime, sha256, objectKey, note = '', uploadedBy, at, keep = 10 }) {
1011
+ const f = workspaceFiles.get(id)
1012
+ if (!f) return null
1013
+ workspaceFileVersions.set(`${id}\n${f.version}`, { fileId: id, version: f.version, size: f.size, sha256: f.sha256, objectKey: f.objectKey, note: f.note, uploadedBy: f.uploadedBy, uploadedAt: f.uploadedAt })
1014
+ Object.assign(f, { version: f.version + 1, size, mime, sha256, objectKey, note, uploadedBy, uploadedAt: at, confirmedAt: null })
1015
+ const old = all(workspaceFileVersions, (v) => v.fileId === id).sort((a, b) => b.version - a.version).slice(keep)
1016
+ for (const v of old) workspaceFileVersions.delete(`${id}\n${v.version}`)
1017
+ return { file: copy(f), droppedKeys: old.map((v) => v.objectKey).filter(Boolean) }
1018
+ },
1019
+ async listWorkspaceFileVersions (id) { return all(workspaceFileVersions, (v) => v.fileId === id).sort((a, b) => b.version - a.version).map(copy) },
1020
+ async confirmWorkspaceFile (id, { size, at }) { const f = workspaceFiles.get(id); if (!f) return null; Object.assign(f, { size, confirmedAt: at }); return copy(f) },
1021
+ async updateWorkspaceFile (id, { path, note }) {
1022
+ const f = workspaceFiles.get(id)
1023
+ if (!f) return null
1024
+ if (path !== undefined && path !== f.path && all(workspaceFiles, (x) => x.workspaceId === f.workspaceId && x.path === path && !x.deletedAt && x.id !== id).length) throw duplicate('file')
1025
+ if (path !== undefined) f.path = path
1026
+ if (note !== undefined) f.note = note
1027
+ return copy(f)
1028
+ },
1029
+ // Moves a folder and everything in it: 'cuts' -> 'final' renames 'cuts', 'cuts/a', 'cuts/x/b'.
1030
+ async renameWorkspaceFolder (workspaceId, from, to, at) {
1031
+ const rows = all(workspaceFiles, (f) => f.workspaceId === workspaceId && !f.deletedAt && (f.path === from || f.path.startsWith(from + '/')))
1032
+ for (const f of rows) f.path = to + f.path.slice(from.length)
1033
+ return rows.length
1034
+ },
1035
+ async deleteWorkspaceFile (id, at) {
1036
+ const f = workspaceFiles.get(id)
1037
+ if (!f) return null
1038
+ const rows = f.kind === 'folder' ? all(workspaceFiles, (x) => x.workspaceId === f.workspaceId && !x.deletedAt && (x.id === id || x.path.startsWith(f.path + '/'))) : [f]
1039
+ for (const x of rows) x.deletedAt = at
1040
+ return copy(f)
1041
+ },
1042
+ async unconfirmedWorkspaceFiles (before) { return all(workspaceFiles, (f) => f.kind === 'file' && !f.confirmedAt && f.uploadedAt < before).map(copy) },
1043
+ async removeWorkspaceFile (id) { removeFile(id) },
1044
+ async sweepDeletedWorkspaceFiles (before) {
1045
+ const gone = all(workspaceFiles, (f) => f.deletedAt && f.deletedAt < before)
1046
+ const keys = []
1047
+ for (const f of gone) {
1048
+ if (f.objectKey) keys.push(f.objectKey)
1049
+ for (const v of all(workspaceFileVersions, (v) => v.fileId === f.id)) if (v.objectKey) keys.push(v.objectKey)
1050
+ removeFile(f.id)
1051
+ }
1052
+ return keys
1053
+ },
1054
+ async workspaceUsage (workspaceId) {
1055
+ const live = all(workspaceFiles, (f) => f.workspaceId === workspaceId && !f.deletedAt && f.kind === 'file')
1056
+ let usedBytes = 0
1057
+ for (const f of live) {
1058
+ if (f.confirmedAt) usedBytes += f.size
1059
+ for (const v of all(workspaceFileVersions, (v) => v.fileId === f.id)) usedBytes += v.size
1060
+ }
1061
+ return { usedBytes, fileCount: live.length }
1062
+ },
1063
+ async setWorkspaceUsage (workspaceId, { usedBytes, fileCount }) { const w = workspaces.get(workspaceId); if (w) Object.assign(w, { usedBytes, fileCount }) },
1064
+ async setWorkspaceFileObjectKey (id, objectKey) { const f = workspaceFiles.get(id); if (!f) return null; f.objectKey = objectKey; return copy(f) },
1065
+ // Undoes a version bump that never confirmed: restores the newest kept version's bytes
1066
+ // onto the file row (with that version's own version number and confirmedAt), and removes
1067
+ // the version row. A file with no earlier version is returned unchanged.
1068
+ async revertWorkspaceFileVersion (id) {
1069
+ const f = workspaceFiles.get(id)
1070
+ if (!f) return null
1071
+ const v = all(workspaceFileVersions, (v) => v.fileId === id).sort((a, b) => b.version - a.version)[0]
1072
+ if (!v) return copy(f)
1073
+ Object.assign(f, { version: v.version, size: v.size, sha256: v.sha256, objectKey: v.objectKey, note: v.note, uploadedBy: v.uploadedBy, uploadedAt: v.uploadedAt, confirmedAt: v.uploadedAt })
1074
+ workspaceFileVersions.delete(`${id}\n${v.version}`)
1075
+ return copy(f)
1076
+ },
1077
+
1078
+ // Test-only views (production reads the tables in the Supabase dashboard).
1079
+ listEvents () { return [...events.values()].map(copy) },
1080
+ listIssues () { return [...issues.values()].map(copy) },
1081
+ async resolveIssue (id) { for (const i of issues.values()) if (i.id === id) i.resolvedAt = now() }
1082
+ }
1083
+ }