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.
- package/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- 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
|
+
}
|