heyquilt 0.3.90 → 0.3.107

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 (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +135 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +142 -6
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +146 -19
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +121 -11
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +140 -28
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. package/src/ui/merges.js +0 -158
@@ -0,0 +1,87 @@
1
+ // Quilt's own downloads and update check, now that the repository with the releases is private.
2
+ // The API reads the latest GitHub release with Quilt's GitHub App (read-only, that repository
3
+ // only) and answers on its own address, so nobody's download depends on the repository being
4
+ // public:
5
+ //
6
+ // GET /v1/releases/latest the release, shaped like GitHub's (tag_name, published_at, body…),
7
+ // with each file's link on this API; the app's update check reads it
8
+ // GET /v1/download/<file> a redirect to GitHub's own short-lived link for that file of the
9
+ // latest release: the bytes never pass through here
10
+ // GET /v1/download/v1.2.3/<file> the same, from that version's release
11
+ import { githubApp } from './github-app.js'
12
+
13
+ export const RELEASES_REPO = { owner: 'DanielCarmichaelGit', name: 'heyquilt' }
14
+ const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
15
+ const TAG = /^v\d+\.\d+\.\d+$/
16
+ const RELEASE_CACHE_MS = 60 * 1000
17
+ const API = 'https://api.github.com'
18
+
19
+ export class ReleaseError extends Error {
20
+ constructor (status, message) { super(message); this.status = status }
21
+ }
22
+
23
+ /** Reads the latest release of `repo` as the GitHub App. `fetch` and `now` are for tests. */
24
+ export function releaseSource ({ github, fetch = globalThis.fetch, now = Date.now, repo = RELEASES_REPO, apiUrl = 'https://api.heyquilt.com' }) {
25
+ if (!github) return null
26
+ const app = githubApp(github, { fetch, now })
27
+ let token = null // { token, expiresAt }
28
+ let release = null // { at, value }
29
+ const tagged = new Map() // tag -> release (a published release's files don't change)
30
+ const auth = async () => {
31
+ if (token && token.expiresAt - now() > 5 * 60 * 1000) return token.token
32
+ const installation = await app.installation(repo.owner, repo.name)
33
+ if (!installation) throw new ReleaseError(503, `Quilt's GitHub App is not installed on ${repo.owner}/${repo.name}`)
34
+ token = await app.repoToken(installation, repo.name, { access: 'read' })
35
+ return token.token
36
+ }
37
+ const get = async (path, { accept = 'application/vnd.github+json', redirect = 'follow' } = {}) => fetch(`${API}${path}`, {
38
+ redirect,
39
+ headers: { 'user-agent': 'quilt-api', accept, 'x-github-api-version': '2022-11-28', authorization: `Bearer ${await auth()}` }
40
+ })
41
+ return {
42
+ /** The latest release, as GitHub has it ({ tag_name, assets, … }); asked at most once a minute. */
43
+ async latest () {
44
+ if (release && now() - release.at < RELEASE_CACHE_MS) return release.value
45
+ const res = await get(`/repos/${repo.owner}/${repo.name}/releases/latest`)
46
+ if (!res.ok) {
47
+ if (release) return release.value // GitHub is having a moment: the last answer stands
48
+ throw new ReleaseError(res.status === 404 ? 404 : 502, res.status === 404 ? 'there is no release yet' : `GitHub answered ${res.status}`)
49
+ }
50
+ release = { at: now(), value: await res.json() }
51
+ return release.value
52
+ },
53
+ /** The latest release as the app's update check reads it, every file linked on this API. */
54
+ async latestPublic () {
55
+ const r = await this.latest()
56
+ return {
57
+ tag_name: r.tag_name,
58
+ name: r.name,
59
+ html_url: 'https://heyquilt.com/download',
60
+ published_at: r.published_at,
61
+ body: r.body || '',
62
+ assets: (r.assets || []).map((a) => ({ name: a.name, size: a.size, browser_download_url: `${apiUrl}/v1/download/${encodeURIComponent(a.name)}` }))
63
+ }
64
+ },
65
+ /** The release tagged `tag` (v1.2.3). */
66
+ async byTag (tag) {
67
+ if (!TAG.test(String(tag))) throw new ReleaseError(404, 'no such version')
68
+ if (tagged.has(tag)) return tagged.get(tag)
69
+ const res = await get(`/repos/${repo.owner}/${repo.name}/releases/tags/${tag}`)
70
+ if (!res.ok) throw new ReleaseError(res.status === 404 ? 404 : 502, res.status === 404 ? `there is no release ${tag}` : `GitHub answered ${res.status}`)
71
+ const value = await res.json()
72
+ if (tagged.size > 200) tagged.clear()
73
+ tagged.set(tag, value)
74
+ return value
75
+ },
76
+ /** GitHub's short-lived link for `file` in the latest release, or in release `tag`. */
77
+ async downloadUrl (file, tag = '') {
78
+ if (!FILE.test(String(file))) throw new ReleaseError(404, 'no such file')
79
+ const asset = ((tag ? await this.byTag(tag) : await this.latest()).assets || []).find((a) => a.name === file)
80
+ if (!asset) throw new ReleaseError(404, `${tag ? `release ${tag}` : 'the latest release'} has no ${file}`)
81
+ const res = await get(`/repos/${repo.owner}/${repo.name}/releases/assets/${asset.id}`, { accept: 'application/octet-stream', redirect: 'manual' })
82
+ const to = res.headers.get('location')
83
+ if (res.status >= 300 && res.status < 400 && to) return to
84
+ throw new ReleaseError(502, `GitHub answered ${res.status} for ${file}`)
85
+ }
86
+ }
87
+ }
@@ -3,18 +3,22 @@
3
3
  import { HttpError, needId, cleanName } from '../http.js'
4
4
  import { parsePublicKey, verifyAgentResume } from '../../identity.js'
5
5
  import { keyStatus } from '../agent-auth.js'
6
- import { encodeAgentEnvKey } from '../../ui/agent-env-key.js'
6
+ import { encodeAgentEnvKey, decodeAgentEnvKey } from '../../ui/agent-env-key.js'
7
+ import { usesByDay, lastDays, keyState, linkSealer } from '../sandbox-keys.js'
7
8
 
8
9
  // Live app keys an agent may hold at once.
9
10
  export const MAX_APP_KEYS = 10
10
11
  const keyName = (v) => cleanName(typeof v === 'string' ? v : '', 40, 'name the key (up to 40 characters), e.g. Pipedream')
11
- const keyView = (k) => ({ id: k.id, name: k.name, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt })
12
+ const keyView = (k) => ({ id: k.id, name: k.name, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt, ...(k.kind === 'sandbox' ? { sandbox: true, hint: k.hint } : {}) })
13
+ const sessionName = (v) => (typeof v === 'string' ? v.trim().slice(0, 80) : '')
12
14
 
13
15
  // Never the key itself, just whether it has one. With a key it joins sessions from a computer
14
16
  // running Quilt; without one it is hosted: it joins through the API's /mcp. Either way it can join.
15
17
  const profileOf = (a) => ({ id: a.id, name: a.name, provider: a.provider, type: a.type, description: a.description, canJoinSessions: true, hosted: !a.publicKey })
16
18
 
17
- export function agentRoutes ({ store, user, person, now, limit, limitTokens, limitStarts, spendResume, resumeWindowMs, agentAuth, apiUrl }) {
19
+ export function agentRoutes ({ store, user, person, now, limit, limitTokens, limitStarts, spendResume, resumeWindowMs, agentAuth, apiUrl, passKey = '' }) {
20
+ // Sandbox keys keep their session's invite link sealed (sandbox-keys.js), for Rotate.
21
+ const links = linkSealer(passKey)
18
22
  // A personal agent of this person's that isn't revoked; anyone else's gets the same 404 as a missing one.
19
23
  async function mine (req, id, who = user) {
20
24
  const u = await who(req)
@@ -22,19 +26,56 @@ export function agentRoutes ({ store, user, person, now, limit, limitTokens, lim
22
26
  if (!agent || agent.ownerUserId !== u.userId || agent.revokedAt) throw new HttpError(404, 'no such agent')
23
27
  return agent
24
28
  }
25
- async function newAppKey (agent, name) {
26
- if ((await store.listAgentAppKeys(agent.id)).length >= MAX_APP_KEYS) throw new HttpError(409, `an agent can have ${MAX_APP_KEYS} app keys; revoke one first`)
27
- return agentAuth.mintAppKey(agent.id, name)
29
+ // `rotating`: a key that replaces one, so the agent holds no more keys than before.
30
+ async function newAppKey (agent, name, sandbox = null, rotating = false) {
31
+ if (!rotating && (await store.listAgentAppKeys(agent.id)).length >= MAX_APP_KEYS) throw new HttpError(409, `an agent can have ${MAX_APP_KEYS} app keys; revoke one first`)
32
+ return agentAuth.mintAppKey(agent.id, name, sandbox)
28
33
  }
29
34
  const connect = (agent, key) => ({ agent: profileOf(agent), key, mcp: `${apiUrl}/mcp` })
30
- // A sandbox's QUILT_AGENT_KEY (ui/agent-env-key.js): a new app key with the API and the session it works in.
31
- async function envKeyFor (agent, session, name) {
32
- let envKey
33
- const check = { api: apiUrl, key: 'qk_check', session, name: agent.name }
34
- try { encodeAgentEnvKey(check) } catch (err) { throw new HttpError(400, err.message) }
35
- const key = await newAppKey(agent, keyName(name || 'Sandbox'))
36
- try { envKey = encodeAgentEnvKey({ ...check, key: key.key }) } catch (err) { throw new HttpError(400, err.message) }
37
- return { envKey, key: keyView(key) }
35
+ /**
36
+ * A sandbox's QUILT_AGENT_KEY (ui/agent-env-key.js): a new app key with the API and the session it
37
+ * works in, kept as a sandbox key (its session, sync slot and hint). `slot` carries a rotated key's on.
38
+ */
39
+ async function envKeyFor (agent, { session, name, label = '', slot, rotating = false }) {
40
+ let made
41
+ const check = { api: apiUrl, key: 'qk_check', session, slot, name: agent.name }
42
+ try { made = decodeAgentEnvKey(encodeAgentEnvKey(check)) } catch (err) { throw new HttpError(400, err.message) }
43
+ const room = made.room
44
+ const known = await store.sessionByRoom(room).catch(() => null)
45
+ // A session keeps the name its other keys show (the app's label is only its folder's name).
46
+ const prior = agent.ownerUserId ? (await personSandboxKeys(agent.ownerUserId)).find((k) => k.room === room && k.sessionName)?.sessionName : null
47
+ const key = await newAppKey(agent, keyName(name || 'Sandbox'), { room, sessionLink: links.seal(made.session), sessionName: known?.name || prior || sessionName(label) || null, slot: made.slot }, rotating)
48
+ const envKey = encodeAgentEnvKey({ ...check, session: made.session, slot: made.slot, key: key.key })
49
+ return { envKey, key: (await sandboxViews([await store.agentAppKeyById(key.id)]))[0] }
50
+ }
51
+ /** Sandbox keys as the Keys table shows them: never the key, the link or the slot. */
52
+ async function sandboxViews (rows) {
53
+ const at = now()
54
+ const uses = await store.appKeyUses(rows.map((k) => k.id), lastDays(at)[0])
55
+ const names = new Map()
56
+ for (const room of new Set(rows.map((k) => k.room))) names.set(room, (await store.sessionByRoom(room).catch(() => null))?.name || '')
57
+ return rows.map((k) => ({
58
+ id: k.id,
59
+ name: k.name,
60
+ hint: k.hint,
61
+ room: k.room,
62
+ session: names.get(k.room) || k.sessionName || k.room,
63
+ createdAt: k.createdAt,
64
+ lastUsedAt: k.lastUsedAt,
65
+ revokedAt: k.revokedAt,
66
+ uses: usesByDay(uses.filter((u) => u.keyId === k.id), at),
67
+ state: keyState(k, at)
68
+ }))
69
+ }
70
+ /** The person's sandbox keys across their agents: the sessions they can make one for by room. */
71
+ async function personSandboxKeys (userId) {
72
+ return store.listSandboxKeys((await store.listPersonalAgents(userId)).map((a) => a.id))
73
+ }
74
+ /** A sandbox key of this agent's, or the same 404 as a missing one. */
75
+ async function sandboxKey (agent, keyId) {
76
+ const k = await store.agentAppKeyById(needId(keyId, 'key'))
77
+ if (!k || k.agentId !== agent.id || k.kind !== 'sandbox') throw new HttpError(404, 'no such key')
78
+ return k
38
79
  }
39
80
 
40
81
  return [
@@ -116,16 +157,70 @@ export function agentRoutes ({ store, user, person, now, limit, limitTokens, lim
116
157
  if (!keyRow) throw new HttpError(403, 'sign in with the agent\'s access key (an app key can\'t make keys)')
117
158
  if (!agent.ownerUserId) throw new HttpError(403, 'only a personal agent makes its own keys; an org agent\'s are made by its org')
118
159
  limit(req)
119
- if (body.session != null && body.session !== '') return envKeyFor(agent, String(body.session), body.name)
160
+ if (body.session != null && body.session !== '') return envKeyFor(agent, { session: String(body.session), name: body.name })
120
161
  return newAppKey(agent, keyName(body.name || 'Sandbox'))
121
162
  }],
122
163
 
123
164
  // A person making a sandbox's QUILT_AGENT_KEY for their agent (the website, the app): a new app key
124
165
  // with the session it works in. Shown once; revoked like any of the agent's app keys.
166
+ // `room` instead of `session`: a session one of this person's sandbox keys already works in
167
+ // (the website's session picker, which never has the link). `sessionName` names it in the table.
125
168
  ['POST', /^\/v1\/agents\/([^/]+)\/env-key$/, async (req, body, [id]) => {
126
169
  const agent = await mine(req, id, person)
127
170
  limit(req)
128
- return envKeyFor(agent, String(body.session || ''), body.name)
171
+ let session = String(body.session || '')
172
+ if (!session && body.room) {
173
+ const u = await person(req)
174
+ const known = (await personSandboxKeys(u.userId)).filter((k) => k.room === String(body.room))
175
+ session = known.map((k) => links.open(k.sessionLink)).find(Boolean) || ''
176
+ if (!session) throw new HttpError(400, 'paste the session\'s invite link')
177
+ }
178
+ return envKeyFor(agent, { session, name: body.name, label: body.sessionName })
179
+ }],
180
+
181
+ // The Keys table on an agent: its sandbox keys (revoked ones until removed), each with its
182
+ // session, last use, requests per day over the last seven days and state; and the sessions
183
+ // a new key can be made for without pasting a link.
184
+ ['GET', /^\/v1\/agents\/([^/]+)\/sandbox-keys$/, async (req, body, [id]) => {
185
+ const agent = await mine(req, id, person)
186
+ const u = await person(req)
187
+ const all = await personSandboxKeys(u.userId)
188
+ const sessions = new Map()
189
+ for (const k of all) if (!sessions.has(k.room) && links.open(k.sessionLink)) sessions.set(k.room, k)
190
+ const named = await sandboxViews([...sessions.values()])
191
+ return {
192
+ keys: await sandboxViews(all.filter((k) => k.agentId === agent.id)),
193
+ sessions: named.map((k) => ({ room: k.room, name: k.session }))
194
+ }
195
+ }],
196
+
197
+ // Rotate: a new key for the same agent, session, name and sync slot (shown once), and the old one revoked.
198
+ ['POST', /^\/v1\/agents\/([^/]+)\/sandbox-keys\/([^/]+)\/rotate$/, async (req, body, [id, keyId]) => {
199
+ const agent = await mine(req, id, person)
200
+ const old = await sandboxKey(agent, keyId)
201
+ if (old.revokedAt) throw new HttpError(404, 'no such key')
202
+ limit(req)
203
+ const session = links.open(old.sessionLink)
204
+ if (!session) throw new HttpError(409, 'make a new key for this session instead (paste its invite link)')
205
+ const made = await envKeyFor(agent, { session, name: old.name, label: old.sessionName || '', slot: old.slot, rotating: true })
206
+ await store.revokeAgentAppKey(agent.id, old.id)
207
+ return { ...made, replaced: old.id }
208
+ }],
209
+
210
+ ['POST', /^\/v1\/agents\/([^/]+)\/sandbox-keys\/([^/]+)\/revoke$/, async (req, body, [id, keyId]) => {
211
+ const agent = await mine(req, id, person)
212
+ const k = await sandboxKey(agent, keyId)
213
+ if (!await store.revokeAgentAppKey(agent.id, k.id)) throw new HttpError(404, 'no such key')
214
+ return { ok: true }
215
+ }],
216
+
217
+ // Remove: a revoked key's row leaves the table (its use counts go with it).
218
+ ['DELETE', /^\/v1\/agents\/([^/]+)\/sandbox-keys\/([^/]+)$/, async (req, body, [id, keyId]) => {
219
+ const agent = await mine(req, id, person)
220
+ const k = await sandboxKey(agent, keyId)
221
+ if (!k.revokedAt) throw new HttpError(409, 'revoke the key first')
222
+ await store.removeAgentAppKey(agent.id, k.id)
223
+ return { ok: true }
129
224
  }],
130
225
 
131
226
  ['GET', /^\/v1\/agents\/([^/]+)\/keys$/, async (req, body, [id]) => {
@@ -8,7 +8,7 @@
8
8
  // or the app collects them: none is ever stored. Quilt never sees a Claude, OpenAI or Cursor token.
9
9
  import crypto from 'node:crypto'
10
10
  import { HttpError, UUID } from '../http.js'
11
- import { newToken, hashToken } from '../tokens.js'
11
+ import { newToken, hashToken, sameHash } from '../tokens.js'
12
12
  import { AGENT_INVITE_TTL_MS } from './agent-invites.js'
13
13
  import { parseInvite, buildInvite } from '../../ui/invite.js'
14
14
  import { isHostedRelay, HOSTED_RELAY } from '../../settings.js'
@@ -27,6 +27,11 @@ const CONTROL = /[\u0000-\u001f\u007f]/g // eslint-disable-line no-control-regex
27
27
  const STEPS = ['starting', 'link', 'code', 'waiting', 'done', 'failed']
28
28
  const CODE = /^[A-Za-z0-9-]{1,40}$/
29
29
  const ROOM = /^[A-Za-z0-9_-]{1,64}$/
30
+ // How often the spare machines are checked (made again, or replaced for a new image).
31
+ export const SPARES_EVERY_MS = 10 * 60 * 1000
32
+ // A spare not ready by then is made again; a claimed spare's join link is kept this long.
33
+ const SPARE_STUCK_MS = 15 * 60 * 1000
34
+ const SPARE_CLAIM_MS = 15 * 60 * 1000
30
35
  const digest = (s) => crypto.createHash('sha256').update(String(s)).digest()
31
36
 
32
37
  /** What the person's app shows of a sign-in step: a link, a device code, whether to paste a code back. */
@@ -63,15 +68,15 @@ export async function cloudAiOf (store, agent) {
63
68
  return store.updateAiVm(theirs.userId, { agentId: agent.id })
64
69
  }
65
70
 
66
- export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log = () => {}, limit, relaySecret = '', relayUrl = HOSTED_RELAY, fly = null, waitMs = WAIT_MS }) {
67
- // user id -> { version, signIn, sessions, waiters, forVm: { follow, leave, signIn, code, waiters } }
71
+ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log = () => {}, limit, relaySecret = '', relayUrl = HOSTED_RELAY, fly = null, waitMs = WAIT_MS, sparesEveryMs = SPARES_EVERY_MS, spareSuspendMs = 1000 }) {
72
+ // user id -> { version, signIn, waiters, forVm: { follow, leave, signIn, code, config, waiters } }
68
73
  const live = new Map()
69
74
  const ownRelay = String(relayUrl).replace(/\/+$/, '').replace(/^http(s?):\/\//, 'ws$1://')
70
75
  const relayWokeAt = new Map()
71
76
 
72
77
  const liveOf = (userId) => {
73
78
  let l = live.get(userId)
74
- if (!l) { l = { version: 1, signIn: null, sessions: [], waiters: new Set(), forVm: { follow: [], leave: [], signIn: false, code: null, waiters: new Set() } }; live.set(userId, l) }
79
+ if (!l) { l = { version: 1, signIn: null, waiters: new Set(), forVm: { follow: [], leave: [], signIn: false, code: null, config: false, waiters: new Set() } }; live.set(userId, l) }
75
80
  return l
76
81
  }
77
82
  const wakeWaiters = (set) => { for (const w of [...set]) w(); set.clear() }
@@ -85,6 +90,105 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
85
90
  set.add(done)
86
91
  })
87
92
 
93
+ // Spare machines (fly-machines.js), by id: { id, volumeId, region, hash, state, ready }. A
94
+ // spare reads the AI tools into memory, says it is ready and is suspended; connecting resumes it.
95
+ const spares = new Map()
96
+ // Spares handed to someone, by id: { link, hash, at }. The machine asks for its join link with
97
+ // its spare key; the link is kept a while, so a machine that restarts first can ask again.
98
+ const claimed = new Map()
99
+ let filling = null
100
+
101
+ /** Makes spares up to `fly.spares`, and replaces those of an older image or that never got ready. One run at a time. */
102
+ function fillSpares () {
103
+ if (!fly || !fly.spares || !fly.listSpares) return Promise.resolve()
104
+ if (filling) return filling
105
+ filling = (async () => {
106
+ for (const [id, c] of claimed) if (now() - c.at > SPARE_CLAIM_MS) claimed.delete(id)
107
+ const there = await fly.listSpares()
108
+ const seen = new Set()
109
+ for (const m of there) {
110
+ if (claimed.has(m.id)) continue
111
+ const had = spares.get(m.id)
112
+ const ready = m.state === 'suspended' || !!(had && had.ready)
113
+ const stuck = !ready && m.createdAt && Date.now() - m.createdAt > SPARE_STUCK_MS
114
+ if (m.image !== fly.image || !m.volumeId || !m.hash || stuck) {
115
+ spares.delete(m.id)
116
+ await fly.destroyMachine(m.id).catch((err) => log(`cloud AI spare ${m.id}: could not delete it: ${err.message}`))
117
+ if (m.volumeId) await fly.destroyVolume(m.volumeId).catch(() => {})
118
+ continue
119
+ }
120
+ seen.add(m.id)
121
+ spares.set(m.id, { id: m.id, volumeId: m.volumeId, region: m.region, hash: m.hash, state: m.state, ready })
122
+ }
123
+ for (const id of [...spares.keys()]) if (!seen.has(id)) spares.delete(id)
124
+ while (spares.size < fly.spares) {
125
+ const short = crypto.randomBytes(10).toString('hex')
126
+ const key = newToken('qs_')
127
+ const volume = await fly.createVolume({ name: `ai_s_${short}` })
128
+ try {
129
+ const m = await fly.createMachine({ name: `ai-s-${short}`, volumeId: volume.id, env: { QUILT_API: apiUrl, QUILT_AI_VM_SPARE: key, HOME: '/data/home' }, spareHash: hashToken(key) })
130
+ spares.set(m.id, { id: m.id, volumeId: volume.id, region: m.region || volume.region || fly.region, hash: hashToken(key), state: 'started', ready: false })
131
+ log(`cloud AI spare ${m.id}: made`)
132
+ } catch (err) {
133
+ await fly.destroyVolume(volume.id).catch(() => {})
134
+ throw err
135
+ }
136
+ }
137
+ })().catch((err) => log(`cloud AI spares: ${err.message}`)).finally(() => { filling = null })
138
+ return filling
139
+ }
140
+ let sparesTimer = null
141
+ if (fly && fly.spares) {
142
+ sparesTimer = setInterval(() => { fillSpares() }, sparesEveryMs)
143
+ sparesTimer.unref?.()
144
+ setImmediate(() => { fillSpares() })
145
+ }
146
+
147
+ /** The spare calling, by its key: { id, spare } before it is claimed, { id, claim } after. */
148
+ function spareFrom (req) {
149
+ const key = bearer(req)
150
+ if (!key || !key.startsWith('qs_')) throw new HttpError(401, 'a spare key is needed')
151
+ const hash = hashToken(key)
152
+ for (const [id, c] of claimed) if (sameHash(c.hash, hash)) return { id, claim: c }
153
+ for (const sp of spares.values()) if (sameHash(sp.hash, hash)) return { id: sp.id, spare: sp }
154
+ throw new HttpError(401, 'no such spare')
155
+ }
156
+
157
+ /**
158
+ * The person's machine: a spare when there is one (a ready one first: it is running, tools in
159
+ * memory, in a second or two), else a new volume and machine. A spare Fly refuses is deleted.
160
+ */
161
+ async function machineFor (userId, env) {
162
+ while (spares.size) {
163
+ const spare = [...spares.values()].sort((a, b) => Number(b.ready) - Number(a.ready))[0]
164
+ spares.delete(spare.id)
165
+ claimed.set(spare.id, { link: env.QUILT_AI_VM_JOIN, hash: spare.hash, at: now() })
166
+ try {
167
+ // One being suspended right now is woken when that is done (the ready route).
168
+ await fly.claimSpare(spare.id, { state: spare.suspending ? 'started' : spare.state })
169
+ fillSpares()
170
+ return { machineId: spare.id, volumeId: spare.volumeId, region: spare.region }
171
+ } catch (err) {
172
+ log(`cloud AI spare ${spare.id}: could not use it: ${err.message}`)
173
+ claimed.delete(spare.id)
174
+ // Fly busy or briefly down: the spare is fine, and stays for the next person. Else it goes.
175
+ if (err.status === 429 || err.status >= 500) { spares.set(spare.id, spare); break }
176
+ await fly.destroyMachine(spare.id).catch(() => {})
177
+ await fly.destroyVolume(spare.volumeId).catch(() => {})
178
+ }
179
+ }
180
+ fillSpares()
181
+ const short = crypto.createHash('sha256').update(userId).digest('hex').slice(0, 20)
182
+ const volume = await fly.createVolume({ name: `ai_${short}` })
183
+ try {
184
+ const m = await fly.createMachine({ name: `ai-${short}`, volumeId: volume.id, env })
185
+ return { machineId: m.id, volumeId: volume.id, region: m.region || volume.region || fly.region }
186
+ } catch (err) {
187
+ await fly.destroyVolume(volume.id).catch(() => {})
188
+ throw err
189
+ }
190
+ }
191
+
88
192
  const needFly = () => { if (!fly) throw new HttpError(503, "Cloud AI isn't set up on this server.") }
89
193
  async function mine (req) {
90
194
  const u = await person(req)
@@ -106,7 +210,8 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
106
210
  error: vm.error,
107
211
  signedIn: vm.signedIn,
108
212
  signIn: l.signIn,
109
- sessions: l.sessions,
213
+ // Kept with the record: shown while the machine sleeps, and after the API restarts.
214
+ sessions: Array.isArray(vm.sessions) ? vm.sessions : [],
110
215
  usageToday: vm.usageToday || {},
111
216
  region: vm.region,
112
217
  lastReportAt: vm.lastReportAt,
@@ -156,7 +261,8 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
156
261
  return { agent, vm }
157
262
  }
158
263
 
159
- const pendingFor = (f) => f.follow.length || f.leave.length || f.signIn || f.code !== null
264
+ // `config`: the settings changed since the machine last asked (a new tool signs in at once).
265
+ const pendingFor = (f) => f.follow.length || f.leave.length || f.signIn || f.code !== null || f.config
160
266
 
161
267
  function cleanSettings (b, vm) {
162
268
  const out = {}
@@ -205,19 +311,11 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
205
311
  const invite = await store.createAgentInvite({ tokenHash: hashToken(token), ownerUserId: u.userId, createdBy: u.userId, expiresAt: now() + AGENT_INVITE_TTL_MS })
206
312
  let vm = await store.createAiVm({ userId: u.userId, tool: body.tool, region: fly.region })
207
313
  vm = await store.updateAiVm(u.userId, { inviteId: invite.id })
208
- const short = crypto.createHash('sha256').update(u.userId).digest('hex').slice(0, 20)
209
- let volume = null
210
314
  try {
211
- volume = await fly.createVolume({ name: `ai_${short}` })
212
- const m = await fly.createMachine({
213
- name: `ai-${short}`,
214
- volumeId: volume.id,
215
- env: { QUILT_API: apiUrl, QUILT_AI_VM_JOIN: `${apiUrl}/v1/join/${token}`, HOME: '/data/home' }
216
- })
217
- vm = await store.updateAiVm(u.userId, { machineId: m.id, volumeId: volume.id, region: m.region || volume.region || fly.region, state: 'starting' })
315
+ const m = await machineFor(u.userId, { QUILT_API: apiUrl, QUILT_AI_VM_JOIN: `${apiUrl}/v1/join/${token}`, HOME: '/data/home' })
316
+ vm = await store.updateAiVm(u.userId, { ...m, state: 'starting' })
218
317
  } catch (err) {
219
318
  log(`cloud AI ${u.userId}: could not create it: ${err.message}`)
220
- if (volume) await fly.destroyVolume(volume.id).catch(() => {})
221
319
  await store.cancelAgentInvite(invite.id).catch(() => {})
222
320
  await store.deleteAiVm(u.userId)
223
321
  throw new HttpError(502, `Couldn't start your cloud AI. ${err.message}`)
@@ -232,6 +330,9 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
232
330
  const patch = cleanSettings(body, vm)
233
331
  if (!Object.keys(patch).length) return view(vm)
234
332
  let up = await store.updateAiVm(u.userId, patch)
333
+ liveOf(u.userId).forVm.config = true
334
+ // A new tool's sign-in starts over: the old tool's link and code are not shown for it.
335
+ if (patch.tool) liveOf(u.userId).signIn = { step: 'starting', url: '', code: '', needsCode: false, error: '' }
235
336
  bump(u.userId)
236
337
  poke(u.userId)
237
338
  up = await wake(up, 'settings')
@@ -310,7 +411,7 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
310
411
  signIn: f.signIn,
311
412
  code: f.code
312
413
  }
313
- Object.assign(f, { follow: [], leave: [], signIn: false, code: null })
414
+ Object.assign(f, { follow: [], leave: [], signIn: false, code: null, config: false })
314
415
  return out
315
416
  }],
316
417
 
@@ -321,7 +422,7 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
321
422
  if (typeof body.signedIn === 'boolean') patch.signedIn = body.signedIn
322
423
  if (body.usageToday !== undefined) patch.usageToday = cleanUsage(body.usageToday)
323
424
  if (body.signIn !== undefined) l.signIn = cleanSignIn(body.signIn)
324
- if (body.sessions !== undefined) l.sessions = cleanSessions(body.sessions)
425
+ if (body.sessions !== undefined) patch.sessions = cleanSessions(body.sessions)
325
426
  await store.updateAiVm(vm.userId, patch)
326
427
  bump(vm.userId)
327
428
  return { ok: true }
@@ -342,6 +443,30 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
342
443
  return { ok: true }
343
444
  }],
344
445
 
446
+ // A spare machine has read the tools into memory: it is suspended (after it has had its
447
+ // answer). It asks again while awake; once someone has taken it, the answer is its join link.
448
+ ['POST', /^\/v1\/ai-vm\/spare\/ready$/, async (req) => {
449
+ const s = spareFrom(req)
450
+ if (s.claim) return { join: s.claim.link }
451
+ const sp = s.spare
452
+ if (!sp.ready) log(`cloud AI spare ${sp.id}: ready`)
453
+ sp.ready = true
454
+ if (fly && !sp.suspending) {
455
+ sp.suspending = new Promise((resolve) => setTimeout(resolve, spareSuspendMs)).then(async () => {
456
+ if (!claimed.has(sp.id)) { await fly.suspend(sp.id); sp.state = 'suspended' }
457
+ // Taken while it was being suspended: wake it for its person.
458
+ if (claimed.has(sp.id)) await fly.start(sp.id)
459
+ }).catch((err) => log(`cloud AI spare ${sp.id}: could not suspend it: ${err.message}`)).finally(() => { sp.suspending = null })
460
+ }
461
+ return { join: null }
462
+ }],
463
+
464
+ // A spare still reading its tools, asking whether someone has taken it: its join link if so.
465
+ ['GET', /^\/v1\/ai-vm\/spare\/claim$/, async (req) => {
466
+ const s = spareFrom(req)
467
+ return { join: s.claim ? s.claim.link : null }
468
+ }],
469
+
345
470
  // The relay: a mention, a message or a task waits for a cloud AI that isn't connected.
346
471
  ['POST', /^\/v1\/relay\/ai-vm\/wake$/, async (req, body) => {
347
472
  if (!relaySecret) throw new HttpError(503, 'the relay is not set up on this server')
@@ -365,5 +490,7 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
365
490
  if (vm) await destroy(vm)
366
491
  }
367
492
 
368
- return { routes, destroyFor }
493
+ const close = () => { if (sparesTimer) clearInterval(sparesTimer) }
494
+
495
+ return { routes, destroyFor, fillSpares, close }
369
496
  }
@@ -14,7 +14,7 @@ const sortRoles = (roles) => [...roles].sort((a, b) => (ROLE_ORDER[a.builtin] ??
14
14
  const orgView = (o) => ({ id: o.id, name: o.name, slug: o.slug, domain: o.domain, domainRequests: o.domainRequests, createdAt: o.createdAt })
15
15
  const roleView = (r) => ({ id: r.id, name: r.name, builtin: r.builtin, grants: normalizeGrants(r.grants), createdAt: r.createdAt })
16
16
 
17
- export function orgRoutes ({ store, user, person }) {
17
+ export function orgRoutes ({ store, user, person, log = () => {} }) {
18
18
  const orgFor = async (req, slug) => { const u = await user(req); return { u, ...(await orgAccess(store, u.userId, slug)) } }
19
19
 
20
20
  // Two orgs made at once can pick the same slug; the unique index decides and we try the next.
@@ -101,6 +101,8 @@ export function orgRoutes ({ store, user, person }) {
101
101
  ['DELETE', /^\/v1\/orgs\/([^/]+)$/, async (req, body, [slug]) => {
102
102
  const a = await orgFor(req, slug)
103
103
  a.needOwner()
104
+ // Its team secrets go with it; never in the way of the delete.
105
+ await store.deleteScopeSecrets('org', a.org.id).catch((err) => log(`team secrets: ${err.message}`))
104
106
  await store.deleteOrg(a.org.id)
105
107
  return { ok: true }
106
108
  }],