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
package/src/ai-vm.js CHANGED
@@ -5,14 +5,19 @@
5
5
  // tool's own login (ai-vm-signin.js), joins the sessions the person joins, and runs "Work on its
6
6
  // own" (autowork.js) there as "<person>'s AI". With nothing to do for a while it asks to be
7
7
  // suspended; anything for it resumes the machine where it was.
8
+ //
9
+ // A spare (QUILT_AI_VM_SPARE, a machine made before anyone connects) first reads every AI tool
10
+ // into memory, says it is ready and is suspended; when someone connects it is resumed, asks for
11
+ // its person's one-time join link and goes on as above, every tool already in memory.
8
12
  import fs from 'node:fs'
9
13
  import path from 'node:path'
10
14
  import http from 'node:http'
11
15
  import { spawn as spawnChild, execFile } from 'node:child_process'
16
+ import { fileURLToPath } from 'node:url'
12
17
  import { quiltHome } from './legacy.js'
13
18
  import { saveSettings, relayUrl } from './settings.js'
14
19
  import { parseInvite } from './ui/invite.js'
15
- import { SIGN_IN, readSignIn, keepToken, keptToken, statusSaysSignedIn } from './ai-vm-signin.js'
20
+ import { SIGN_IN, readSignIn, codeRefused, keepToken, keptToken, statusSaysSignedIn } from './ai-vm-signin.js'
16
21
  import { tokensOn } from './autowork.js'
17
22
  import { writePrivateJson } from './private-file.js'
18
23
 
@@ -21,6 +26,12 @@ export const IDLE_MS = 10 * 60 * 1000
21
26
  const REPORT_MS = 60 * 1000
22
27
  const RETRY_MS = 5 * 1000
23
28
  const SIGN_IN_MS = 15 * 60 * 1000 // a sign-in nobody finishes is stopped
29
+ // After a pasted code, how long a tool has to finish before the code counts as not taken.
30
+ const CODE_MS = 20 * 1000
31
+ const CLAIM_EVERY_MS = 500
32
+ const BIG_FILE = 1024 * 1024
33
+ // Quilt's own install (its app folder and the node it runs on), read into memory by a spare too.
34
+ const QUILT_DIRS = [path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), path.resolve(path.dirname(process.execPath), '..')]
24
35
  const ROOM = /^[A-Za-z0-9_-]{1,64}$/
25
36
 
26
37
  /**
@@ -33,7 +44,11 @@ export function spawnLogin (exe, args, { env = {}, useScript = process.platform
33
44
  const [cmd, argv] = useScript
34
45
  ? ['script', ['-q', '-f', '-e', '-c', `stty cols 2000 rows 50 2>/dev/null; exec ${[exe, ...args].map(quote).join(' ')}`, '/dev/null']]
35
46
  : [exe, args]
36
- const child = spawnChild(cmd, argv, { env: { ...process.env, ...env, TERM: 'xterm-256color', BROWSER: 'echo' }, stdio: ['pipe', 'pipe', 'pipe'] })
47
+ // No BROWSER: with none to open (the machine has none) a tool shows its link at once. Given a
48
+ // command that "opens" it, Claude waits 3 s for the browser before it prints the link.
49
+ const all = { ...process.env, TERM: 'xterm-256color', ...env }
50
+ if (!('BROWSER' in env)) delete all.BROWSER
51
+ const child = spawnChild(cmd, argv, { env: all, stdio: ['pipe', 'pipe', 'pipe'] })
37
52
  const listeners = []
38
53
  const onChunk = (d) => { for (const l of listeners) l(String(d)) }
39
54
  child.stdout.on('data', onChunk)
@@ -47,6 +62,45 @@ export function spawnLogin (exe, args, { env = {}, useScript = process.platform
47
62
  }
48
63
  }
49
64
 
65
+ /**
66
+ * Reads a tool's own files into memory, so its first run doesn't wait on the disk: every file of
67
+ * 1 MB or more in the folder it is installed in (its npm package, or the folder of its program).
68
+ */
69
+ export async function warmTool (exe, stop = () => false) {
70
+ let real
71
+ try { real = fs.realpathSync(exe) } catch { return 0 }
72
+ let root = path.dirname(real)
73
+ for (let d = root; d !== path.dirname(d); d = path.dirname(d)) if (fs.existsSync(path.join(d, 'package.json'))) { root = d; break }
74
+ return warmFolder(root, BIG_FILE, stop)
75
+ }
76
+
77
+ /** Reads every file of `min` bytes or more under `root` into memory, until `stop()`; returns how many bytes. */
78
+ export async function warmFolder (root, min = 0, stop = () => false) {
79
+ let bytes = 0
80
+ const walk = async (dir, depth) => {
81
+ let entries = []
82
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
83
+ for (const e of entries) {
84
+ if (stop()) return
85
+ const f = path.join(dir, e.name)
86
+ if (e.isDirectory() && depth < 8) await walk(f, depth + 1)
87
+ else if (e.isFile()) {
88
+ try {
89
+ if (fs.statSync(f).size < min) continue
90
+ await new Promise((resolve) => {
91
+ const r = fs.createReadStream(f, { highWaterMark: 4 * 1024 * 1024 })
92
+ r.on('data', (c) => { bytes += c.length; if (stop()) { r.destroy(); resolve() } })
93
+ r.on('end', resolve)
94
+ r.on('error', resolve)
95
+ })
96
+ } catch {}
97
+ }
98
+ }
99
+ }
100
+ await walk(root, 0)
101
+ return bytes
102
+ }
103
+
50
104
  /** A command's exit code and output (for the tools' status commands); -1 when it can't run. */
51
105
  function runQuiet (exe, args) {
52
106
  return new Promise((resolve) => {
@@ -60,9 +114,13 @@ export class AiVm {
60
114
  * reaches outside is injectable for tests: `fetch`, `login` (spawnLogin), `status` (runQuiet),
61
115
  * `join` (runSession), `agentJoin`/`agentAccess` (agent-join.js), `autowork`, `which`.
62
116
  */
63
- constructor ({ api, joinLink = '', home = process.env.HOME, fetch: fetchImpl = globalThis.fetch, login = spawnLogin, status = runQuiet, join = null, agent = null, autowork = null, which = null, now = Date.now, log = console.log, idleMs = IDLE_MS, reportMs = REPORT_MS, port = 8080, waitParam = '1' }) {
117
+ constructor ({ api, joinLink = '', spareKey = '', home = process.env.HOME, fetch: fetchImpl = globalThis.fetch, login = spawnLogin, status = runQuiet, warm = warmTool, join = null, agent = null, autowork = null, which = null, now = Date.now, log = console.log, idleMs = IDLE_MS, reportMs = REPORT_MS, port = 8080, waitParam = '1', claimEveryMs = CLAIM_EVERY_MS, codeMs = CODE_MS }) {
64
118
  this.api = String(api || '').replace(/\/+$/, '')
65
119
  this.joinLink = joinLink
120
+ this.spareKey = spareKey
121
+ this.warm = warm
122
+ this.claimEveryMs = claimEveryMs
123
+ this.codeMs = codeMs
66
124
  this.home = home
67
125
  this.fetch = fetchImpl
68
126
  this.login = login
@@ -98,10 +156,15 @@ export class AiVm {
98
156
  return this.agentImpl
99
157
  }
100
158
 
159
+ async hasAgent () {
160
+ const a = await this.agent()
161
+ try { a.readAgent({ name: AGENT_NAME }); return true } catch { return false }
162
+ }
163
+
101
164
  /** Joins Quilt as the person's agent with the machine's one-time link, unless it already has. */
102
165
  async ensureAgent () {
103
166
  const a = await this.agent()
104
- try { a.readAgent({ name: AGENT_NAME }); return } catch {}
167
+ if (await this.hasAgent()) return
105
168
  if (!this.joinLink) throw new Error('This machine has no Quilt agent and no link to join with (QUILT_AI_VM_JOIN).')
106
169
  await a.join({ link: this.joinLink, name: AGENT_NAME, provider: 'Quilt cloud AI', type: 'cloud AI', description: "A person's AI on its own cloud machine", log: (l) => this.log(l) })
107
170
  }
@@ -121,8 +184,59 @@ export class AiVm {
121
184
 
122
185
  // ------------------------------------------------------------- startup --
123
186
 
187
+ /** The API, as a spare (its key, not an agent's). Short: a request cut by a suspend is given up quickly. */
188
+ async spareCall (method, route) {
189
+ const res = await this.fetch(`${this.api}${route}`, { method, headers: { authorization: `Bearer ${this.spareKey}` }, signal: AbortSignal.timeout(1500) })
190
+ const data = await res.json().catch(() => null)
191
+ if (!res.ok) throw Object.assign(new Error((data && data.error) || `the API answered ${res.status}`), { status: res.status })
192
+ return data
193
+ }
194
+
195
+ /**
196
+ * A spare: reads every tool into memory, says it is ready (and is suspended), then waits to be
197
+ * taken. Returns when it has its person's one-time join link.
198
+ */
199
+ async spare () {
200
+ const started = this.now()
201
+ let bytes = 0
202
+ // Someone may take it while it reads (a person connecting just after the last spare went):
203
+ // it stops reading then and gets on with their sign-in.
204
+ const taken = () => !!this.joinLink || this.stopped
205
+ const watch = setInterval(() => {
206
+ this.spareCall('GET', '/v1/ai-vm/spare/claim').then((r) => { if (r && r.join && !this.joinLink) { this.joinLink = r.join; this.log('spare: taken while reading the tools') } }, () => {})
207
+ }, this.claimEveryMs)
208
+ watch.unref?.()
209
+ try {
210
+ for (const t of Object.values(SIGN_IN)) {
211
+ const exe = this.which(t.exe)
212
+ if (exe && !taken()) bytes += await this.warm(exe, taken)
213
+ }
214
+ // Quilt's own files and the modules it loads once it has a person, so nothing waits on the disk then.
215
+ if (this.warm === warmTool) for (const d of QUILT_DIRS) if (!taken()) bytes += await warmFolder(d, 0, taken)
216
+ await Promise.all([import('./agent-join.js'), import('./autowork.js'), import('./runner.js'), import('./pass-source.js')]).catch(() => {})
217
+ } finally { clearInterval(watch) }
218
+ if (this.joinLink) return
219
+ this.log(`spare: the tools are in memory (${Math.round(bytes / 1048576)} MB in ${Math.round((this.now() - started) / 1000)}s)`)
220
+ // Each "ready" is answered with the join link once someone has taken this machine; until
221
+ // then the API suspends it (and again, should it ever wake up untaken).
222
+ let said = false
223
+ while (!this.stopped && !this.joinLink) {
224
+ try {
225
+ const r = await this.spareCall('POST', '/v1/ai-vm/spare/ready')
226
+ if (!said) this.log('spare: ready')
227
+ said = true
228
+ if (r && r.join) { this.joinLink = r.join; this.log('spare: taken'); break }
229
+ } catch (err) {
230
+ if (err.status === 401) this.log(`spare: ${err.message}`)
231
+ }
232
+ await new Promise((resolve) => setTimeout(resolve, this.claimEveryMs))
233
+ }
234
+ }
235
+
124
236
  async start () {
125
237
  fs.mkdirSync(this.dir, { recursive: true })
238
+ if (this.spareKey && !(await this.hasAgent())) await this.spare()
239
+ if (this.stopped) return
126
240
  await this.ensureAgent()
127
241
  // A kept Claude token signs Claude's runs in (it never leaves this machine).
128
242
  const token = keptToken(this.home, 'Claude Code')
@@ -208,6 +322,8 @@ export class AiVm {
208
322
  if (!exe) { this.setStep({ step: 'failed', error: `${tool} isn't installed on this machine` }); return }
209
323
  let out = ''
210
324
  let shown = ''
325
+ let sinceCode = null // what the tool printed since a code was typed into it
326
+ let stale = '' // the link of a code that wasn't taken: never shown again
211
327
  const proc = this.login(exe, t.args, { env: t.env })
212
328
  this.signing = proc
213
329
  this.lastActivity = this.now()
@@ -216,19 +332,44 @@ export class AiVm {
216
332
  timer.unref?.()
217
333
  proc.onData((d) => {
218
334
  out = (out + d).slice(-64 * 1024)
335
+ if (sinceCode !== null) sinceCode = (sinceCode + d).slice(-16 * 1024)
219
336
  const r = readSignIn(tool, out)
220
337
  if (r.token) {
221
338
  keepToken(this.home, tool, r.token)
222
339
  process.env.CLAUDE_CODE_OAUTH_TOKEN = r.token
340
+ sinceCode = null
223
341
  }
342
+ if (sinceCode !== null && codeRefused(tool, sinceCode)) refused()
224
343
  const key = `${r.url}\n${r.code}`
225
- if (r.url && key !== shown && this.signIn?.step !== 'waiting') {
344
+ if (r.url && r.url !== stale && key !== shown && this.signIn?.step !== 'waiting') {
226
345
  shown = key
227
- this.setStep({ step: r.code ? 'code' : 'link', url: r.url, code: r.code, needsCode: r.needsCode })
346
+ this.setStep({ step: r.code ? 'code' : 'link', url: r.url, code: r.code, needsCode: r.needsCode, error: this.refusedError || '' })
228
347
  }
229
348
  })
349
+ // A code the tool didn't take: its sign-in starts over with a new link (Claude: Enter retries).
350
+ const refused = () => {
351
+ if (this.signing !== proc || sinceCode === null) return
352
+ sinceCode = null
353
+ clearTimeout(this.codeTimer)
354
+ this.refusedError = `${tool} didn't take that code. Open the new link and paste the code it gives you.`
355
+ this.log('sign-in: the code was not taken; starting over')
356
+ if (t.retry) proc.write(t.retry)
357
+ // Only what comes after counts: the old link is not shown again, even redrawn.
358
+ stale = readSignIn(tool, out).url || stale
359
+ out = ''
360
+ shown = ''
361
+ this.setStep({ step: 'starting', error: this.refusedError })
362
+ }
363
+ this.codeGiven = () => {
364
+ sinceCode = ''
365
+ clearTimeout(this.codeTimer)
366
+ this.codeTimer = setTimeout(refused, this.codeMs)
367
+ this.codeTimer.unref?.()
368
+ }
369
+ this.refusedError = ''
230
370
  proc.exited.then(async (code) => {
231
371
  clearTimeout(timer)
372
+ if (this.signing === proc) clearTimeout(this.codeTimer)
232
373
  if (this.signing !== proc) return
233
374
  this.signing = null
234
375
  this.signedIn = await this.checkSignedIn()
@@ -241,7 +382,8 @@ export class AiVm {
241
382
  giveCode (code) {
242
383
  if (!this.signing) { this.log('a sign-in code came with no sign-in running'); return }
243
384
  this.signing.write(`${code}\r`)
244
- this.setStep({ ...(this.signIn || {}), step: 'waiting', needsCode: false })
385
+ if (this.codeGiven) this.codeGiven()
386
+ this.setStep({ ...(this.signIn || {}), step: 'waiting', needsCode: false, error: '' })
245
387
  }
246
388
 
247
389
  setStep (step) {
@@ -370,7 +512,7 @@ export function onPath (names, { env = process.env } = {}) {
370
512
  /** `quilt ai-vm`, from the machine's environment. */
371
513
  export async function runAiVm (env = process.env) {
372
514
  if (!env.QUILT_API) throw new Error('QUILT_API is not set: this command runs on a cloud AI machine Quilt started.')
373
- const vm = new AiVm({ api: env.QUILT_API, joinLink: env.QUILT_AI_VM_JOIN || '', home: env.HOME, which: (names) => onPath(names), port: Number(env.PORT || 8080) })
515
+ const vm = new AiVm({ api: env.QUILT_API, joinLink: env.QUILT_AI_VM_JOIN || '', spareKey: env.QUILT_AI_VM_SPARE || '', home: env.HOME, which: (names) => onPath(names), port: Number(env.PORT || 8080) })
374
516
  const shutdown = async () => { await vm.stop(); process.exit(0) }
375
517
  process.on('SIGINT', shutdown)
376
518
  process.on('SIGTERM', shutdown)
@@ -11,6 +11,7 @@
11
11
  import crypto from 'node:crypto'
12
12
  import { newToken, hashToken } from './tokens.js'
13
13
  import { HttpError } from './http.js'
14
+ import { dayOf, hintOf } from './sandbox-keys.js'
14
15
 
15
16
  export const ACCESS_TTL_MS = 60 * 60 * 1000
16
17
  export const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000
@@ -93,10 +94,13 @@ export function makeAgentAuth ({ store, now, bearer }) {
93
94
  return resume(agent)
94
95
  }
95
96
 
96
- /** A new app key (qk_) for an agent: shown once, only its hash is stored. */
97
- async function mintAppKey (agentId, name) {
97
+ /**
98
+ * A new app key (qk_) for an agent: shown once, only its hash is stored. `sandbox` ({ room,
99
+ * sessionLink, sessionName, slot }) makes it a sandbox key, which also keeps its last four characters.
100
+ */
101
+ async function mintAppKey (agentId, name, sandbox = null) {
98
102
  const key = newToken('qk_')
99
- const row = await store.createAgentAppKey({ agentId, name, keyHash: hashToken(key) })
103
+ const row = await store.createAgentAppKey({ agentId, name, keyHash: hashToken(key), ...(sandbox ? { ...sandbox, kind: 'sandbox', hint: hintOf(key) } : {}) })
100
104
  return { id: row.id, name: row.name, createdAt: row.createdAt, key }
101
105
  }
102
106
 
@@ -108,6 +112,8 @@ export function makeAgentAuth ({ store, now, bearer }) {
108
112
  const agent = await store.agentById(row.agentId)
109
113
  if (!agent || agent.revokedAt) throw new HttpError(401, 'this agent was revoked')
110
114
  if (!row.lastUsedAt || now() - row.lastUsedAt >= TOUCH_EVERY_MS) await store.touchAgentAppKey(row.id)
115
+ // Every request counts toward the key's use that day (the seven-day bars); a lost count never fails one.
116
+ await store.countAppKeyUse(row.id, dayOf(now())).catch(() => {})
111
117
  if (!agent.lastUsedAt || now() - agent.lastUsedAt >= TOUCH_EVERY_MS) await store.touchAgent(agent.id)
112
118
  return { agent, keyRow: null, appKey: row }
113
119
  }
@@ -1,11 +1,19 @@
1
1
  // The Fly Machines API, for people's cloud AI machines (routes/ai-vm.js): one machine and one
2
2
  // volume per person, in the app AI_VM_APP (never the relay's). Plain fetch, so tests point it
3
3
  // at a fake server. See https://fly.io/docs/machines/api/.
4
+ //
5
+ // Spares: machines made ahead of anyone connecting, with their volume, running `quilt ai-vm` as
6
+ // a spare: it reads the AI tools into memory (a tool read cold off the disk takes up to 18 s
7
+ // before it prints anything) and is then suspended, memory and all, which costs only its disk.
8
+ // Connecting relabels one as the person's and resumes it: it is running in a second or two, with
9
+ // every tool ready, and takes its one-time join link from the API.
4
10
 
5
11
  export const FLY_API = 'https://api.machines.dev/v1'
6
12
  // Small enough to suspend (Fly suspends machines of 2 GB or less) and to cost little.
7
13
  export const GUEST = { cpu_kind: 'shared', cpus: 2, memory_mb: 2048 }
8
14
  export const VOLUME_GB = 5
15
+ export const SPARE = 'ai-vm-spare'
16
+ const OWNED = 'ai-vm'
9
17
  const ID = /^[A-Za-z0-9_-]{1,64}$/
10
18
 
11
19
  const needId = (id, what) => {
@@ -17,10 +25,36 @@ const needId = (id, what) => {
17
25
  * A client for one Fly app. `token` is FLY_API_TOKEN; `image` the AI machine image
18
26
  * (registry.fly.io/<app>:<tag>); `base` and `fetch` are for tests.
19
27
  */
20
- export function flyMachines ({ token, app, image, region = 'iad', base = FLY_API, fetch: fetchImpl = globalThis.fetch }) {
28
+ export function flyMachines ({ token, app, image, region = 'iad', spares = 0, base = FLY_API, fetch: fetchImpl = globalThis.fetch, retryMs = 1000 }) {
21
29
  const root = `${String(base).replace(/\/+$/, '')}/apps/${encodeURIComponent(app)}`
22
30
 
23
- async function call (method, path, body, { gone = false } = {}) {
31
+ /**
32
+ * The image's own command, restarted on a crash. A spare's metadata says so, with the hash of
33
+ * the key it asks the API for its claim with (`spareHash`).
34
+ */
35
+ const config = ({ volumeId, env = {}, spareHash = '' }) => ({
36
+ image,
37
+ env,
38
+ guest: GUEST,
39
+ mounts: [{ volume: needId(volumeId, 'volume'), path: '/data' }],
40
+ restart: { policy: 'on-failure', max_retries: 5 },
41
+ auto_destroy: false,
42
+ metadata: spareHash ? { quilt: SPARE, quilt_spare: spareHash } : { quilt: OWNED }
43
+ })
44
+
45
+ // Fly lets a few writes through at once, then answers 429 for a moment: those are asked again.
46
+ async function call (method, path, body, opts = {}) {
47
+ for (let tries = 1; ; tries++) {
48
+ try {
49
+ return await callOnce(method, path, body, opts)
50
+ } catch (err) {
51
+ if (err.status !== 429 || tries >= 5) throw err
52
+ await new Promise((resolve) => setTimeout(resolve, retryMs * tries))
53
+ }
54
+ }
55
+ }
56
+
57
+ async function callOnce (method, path, body, { gone = false } = {}) {
24
58
  let res
25
59
  try {
26
60
  res = await fetchImpl(root + path, {
@@ -43,22 +77,42 @@ export function flyMachines ({ token, app, image, region = 'iad', base = FLY_API
43
77
  app,
44
78
  image,
45
79
  region,
80
+ spares,
46
81
  /** A new volume for one person's machine; encrypted, in this app's region. */
47
82
  createVolume: async ({ name, sizeGb = VOLUME_GB }) => call('POST', '/volumes', { name, region, size_gb: sizeGb, encrypted: true }),
48
83
  /** A machine running the AI image with the volume as /data. It restarts on a crash, never on its own after a stop. */
49
- createMachine: async ({ name, volumeId, env = {} }) => call('POST', '/machines', {
50
- name,
51
- region,
52
- config: {
53
- image,
54
- env,
55
- guest: GUEST,
56
- mounts: [{ volume: needId(volumeId, 'volume'), path: '/data' }],
57
- restart: { policy: 'on-failure', max_retries: 5 },
58
- auto_destroy: false,
59
- metadata: { quilt: 'ai-vm' }
84
+ createMachine: async ({ name, volumeId, env = {}, spareHash = '' }) => call('POST', '/machines', { name, region, config: config({ volumeId, env, spareHash }) }),
85
+ /** The spare machines there are now: { id, volumeId, image, state, hash, region, createdAt }. */
86
+ listSpares: async () => {
87
+ const list = await call('GET', `/machines?metadata.quilt=${SPARE}`)
88
+ return (Array.isArray(list) ? list : [])
89
+ .filter((m) => m && m.config && m.config.metadata && m.config.metadata.quilt === SPARE && ID.test(String(m.id)))
90
+ .map((m) => ({
91
+ id: m.id,
92
+ volumeId: (m.config.mounts && m.config.mounts[0] && m.config.mounts[0].volume) || '',
93
+ image: m.config.image || '',
94
+ state: m.state || '',
95
+ hash: String(m.config.metadata.quilt_spare || ''),
96
+ region: m.region || region,
97
+ createdAt: Date.parse(m.created_at || '') || 0
98
+ }))
99
+ },
100
+ /**
101
+ * Makes a spare a person's machine and wakes it, as it is: relabelling needs no restart, so
102
+ * the tools it read stay in memory. A spare still starting up is left to finish.
103
+ */
104
+ claimSpare: async (id, { state = '' } = {}) => {
105
+ // Its key's hash stays in its metadata, unused: the API knows the machine is taken.
106
+ await call('POST', `/machines/${needId(id, 'machine')}/metadata/quilt`, { value: OWNED })
107
+ if (state === 'started') return
108
+ try {
109
+ await call('POST', `/machines/${needId(id, 'machine')}/start`)
110
+ } catch (err) {
111
+ // Not woken: it is a spare again, for the next person.
112
+ await call('POST', `/machines/${needId(id, 'machine')}/metadata/quilt`, { value: SPARE }).catch(() => {})
113
+ throw err
60
114
  }
61
- }),
115
+ },
62
116
  getMachine: async (id) => call('GET', `/machines/${needId(id, 'machine')}`),
63
117
  /** Starts a stopped machine, or resumes a suspended one where it was. */
64
118
  start: async (id) => call('POST', `/machines/${needId(id, 'machine')}/start`),
@@ -72,5 +126,8 @@ export function flyMachines ({ token, app, image, region = 'iad', base = FLY_API
72
126
  /** The client from the API's environment, or null when cloud AI machines aren't set up here. */
73
127
  export function flyFromEnv (env = process.env) {
74
128
  if (!env.FLY_API_TOKEN || !env.AI_VM_APP || !env.AI_VM_IMAGE) return null
75
- return flyMachines({ token: env.FLY_API_TOKEN, app: env.AI_VM_APP, image: env.AI_VM_IMAGE, region: env.AI_VM_REGION || 'iad' })
129
+ // AI_VM_SPARES: how many spare machines wait ready for the next people to connect (2 unless set:
130
+ // one is still ready while the one after a connect is being made, which takes a minute or two).
131
+ const spares = env.AI_VM_SPARES === undefined || env.AI_VM_SPARES === '' ? 2 : Math.max(0, Math.min(20, Math.floor(Number(env.AI_VM_SPARES)) || 0))
132
+ return flyMachines({ token: env.FLY_API_TOKEN, app: env.AI_VM_APP, image: env.AI_VM_IMAGE, region: env.AI_VM_REGION || 'iad', spares })
76
133
  }
@@ -34,6 +34,7 @@ export function createMemoryStore ({ now = Date.now } = {}) {
34
34
  const users = new Map(); const orgs = new Map(); const roles = new Map(); const members = new Map()
35
35
  const teams = new Map(); const teamMembers = new Map(); const invites = new Map(); const requests = new Map()
36
36
  const agentInvites = new Map(); const keyRows = new Map(); const appKeys = new Map()
37
+ const appKeyUses = new Map() // "key id\nday" -> requests that day (agent_app_key_uses)
37
38
  const events = new Map(); const issues = new Map()
38
39
  const relaySessions = new Map(); const visits = new Map(); const seenEvents = new Map(); const actions = new Map()
39
40
  const accessTypes = new Map(); const grants = new Map(); const sessionInvites = new Map()
@@ -42,10 +43,16 @@ export function createMemoryStore ({ now = Date.now } = {}) {
42
43
  const agentPlacements = new Map(); const workspaceAgentOverrides = new Map()
43
44
  const sessionAgentExclusions = new Map(); const agentWebhooks = new Map()
44
45
  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
+ // The relay on many machines (20261017100000_relay_cluster.sql, 20261018100000_relay_autoscale.sql).
46
47
  const relayMachines = new Map(); const relayRooms = new Map(); const relayJobs = new Map()
47
48
  const hostedRooms = new Map() // "account\nroom" -> { account, room, addedAt }
48
49
  const hostedWebhooks = new Map(); const resumeSignatures = new Map()
50
+ // Secrets (20261018000000_secrets.sql): only what the vault sealed.
51
+ const secretRows = new Map(); const secretDataKeys = new Map(); const secretSettingsRows = new Map()
52
+ const secretTickets = new Map(); const secretRuns = new Map(); const secretAsks = new Map(); const secretUses = []
53
+ const scopeKey = (scopeType, scopeId) => `${scopeType}\n${scopeId}`
54
+ // A secret as the API's routes see it: never its sealed value (secretSealed reads that, for the vault).
55
+ const secretMeta = (r) => { if (!r) return null; const { sealedValue, ...rest } = copy(r); return rest }
49
56
  const wmKey = (workspaceId, account) => `${workspaceId}\n${account}`
50
57
  const grantKey = (room, account) => `${room}\n${account}`
51
58
  const waoKey = (workspaceId, agentId) => `${workspaceId}\n${agentId}`
@@ -236,11 +243,36 @@ export function createMemoryStore ({ now = Date.now } = {}) {
236
243
  },
237
244
 
238
245
  // App keys (qk_): hashes only, and they don't run out.
239
- async createAgentAppKey ({ agentId, name, keyHash }) {
246
+ // A sandbox key (kind 'sandbox') also has its session, slot and hint; a plain app key has none.
247
+ async createAgentAppKey ({ agentId, name, keyHash, kind = 'app', hint = null, room = null, sessionLink = null, sessionName = null, slot = null }) {
240
248
  if (!agents.has(agentId)) throw fkViolation('agent', 'does not exist')
241
- const row = { id: uuid(), agentId, name, keyHash, createdAt: now(), lastUsedAt: null, revokedAt: null }
249
+ if (kind === 'sandbox' && !(room && sessionLink && slot)) throw checkViolation('a sandbox key has a session and a slot')
250
+ const row = { id: uuid(), agentId, name, keyHash, createdAt: now(), lastUsedAt: null, revokedAt: null, kind, hint, room, sessionLink, sessionName, slot }
242
251
  appKeys.set(row.id, row); return copy(row)
243
252
  },
253
+ async agentAppKeyById (id) { return copy(appKeys.get(id)) },
254
+ // These agents' sandbox keys, revoked ones too (until removed), newest first.
255
+ async listSandboxKeys (agentIds) {
256
+ const ids = new Set(agentIds)
257
+ return all(appKeys, (k) => ids.has(k.agentId) && k.kind === 'sandbox').sort((a, b) => b.createdAt - a.createdAt).map(copy)
258
+ },
259
+ // Only a revoked key, and only one of this agent's; its use counts go with it.
260
+ async removeAgentAppKey (agentId, id) {
261
+ const k = appKeys.get(id)
262
+ if (!k || k.agentId !== agentId || !k.revokedAt) return false
263
+ appKeys.delete(id)
264
+ for (const u of [...appKeyUses.keys()]) if (u.startsWith(`${id}\n`)) appKeyUses.delete(u)
265
+ return true
266
+ },
267
+ async countAppKeyUse (keyId, day) {
268
+ if (!appKeys.has(keyId)) throw fkViolation('app key', 'does not exist')
269
+ const u = `${keyId}\n${day}`
270
+ appKeyUses.set(u, (appKeyUses.get(u) || 0) + 1)
271
+ },
272
+ async appKeyUses (keyIds, sinceDay) {
273
+ const ids = new Set(keyIds)
274
+ return [...appKeyUses].map(([u, count]) => { const [keyId, day] = u.split('\n'); return { keyId, day, count } }).filter((r) => ids.has(r.keyId) && r.day >= sinceDay)
275
+ },
244
276
  async agentAppKeyByHash (h) { return copy(all(appKeys, (k) => k.keyHash === h)[0]) },
245
277
  async listAgentAppKeys (agentId) {
246
278
  return all(appKeys, (k) => k.agentId === agentId && !k.revokedAt).sort((a, b) => a.createdAt - b.createdAt).map(copy)
@@ -359,7 +391,7 @@ export function createMemoryStore ({ now = Date.now } = {}) {
359
391
  async relayMachine (id) { return copy(relayMachines.get(id)) },
360
392
  async relayMachines () { return [...relayMachines.values()].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map(copy) },
361
393
  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() }
394
+ const cur = relayMachines.get(id) || { id, region: '', capacity: 50, state: 'active', dedicatedRoom: null, load: {}, hotAt: null, overAt: null, emptyAt: null, helloAt: null, reportedAt: null, createdAt: now() }
363
395
  for (const [k, v] of Object.entries(fields)) if (v !== undefined) cur[k] = structuredClone(v)
364
396
  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
397
  relayMachines.set(id, cur)
@@ -375,7 +407,7 @@ export function createMemoryStore ({ now = Date.now } = {}) {
375
407
  // Mirrors place_relay_room: placed once; the room stays where it was put.
376
408
  async placeRelayRoom (room, machineId) {
377
409
  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 })
410
+ if (!relayRooms.has(room)) relayRooms.set(room, { room, machineId, state: 'placed', stats: {}, busyMinutes: 0, heavy: [], placedAt: now(), reportedAt: null, movedAt: null })
379
411
  return copy(relayRooms.get(room))
380
412
  },
381
413
  async setRelayRoom (room, patch) {
@@ -981,7 +1013,7 @@ export function createMemoryStore ({ now = Date.now } = {}) {
981
1013
  async createAiVm ({ userId, tool, model = '', prompt = '', cap = 0, region = '' }) {
982
1014
  if (!profiles.has(userId)) throw fkViolation('person', 'does not exist')
983
1015
  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() }
1016
+ const row = { userId, tool, model, prompt, cap, agentId: null, inviteId: null, machineId: null, volumeId: null, region, state: 'creating', error: '', signedIn: false, usageToday: {}, sessions: [], lastReportAt: null, createdAt: now(), updatedAt: now() }
985
1017
  aiVms.set(userId, row); return copy(row)
986
1018
  },
987
1019
  async aiVmOf (userId) { return copy(aiVms.get(userId)) },
@@ -1075,6 +1107,110 @@ export function createMemoryStore ({ now = Date.now } = {}) {
1075
1107
  return copy(f)
1076
1108
  },
1077
1109
 
1110
+ // Secrets (see 20261018000000_secrets.sql).
1111
+ async listSecrets (scopeType, scopeId) { return all(secretRows, (r) => r.scopeType === scopeType && r.scopeId === scopeId).sort((a, b) => a.name.localeCompare(b.name)).map(secretMeta) },
1112
+ async secretByName (scopeType, scopeId, name) { return secretMeta(all(secretRows, (r) => r.scopeType === scopeType && r.scopeId === scopeId && r.name === name)[0]) },
1113
+ async secretById (id) { return secretMeta(secretRows.get(id)) },
1114
+ async secretSealed (id) { const r = secretRows.get(id); return r ? { sealedValue: r.sealedValue, keyVersion: r.keyVersion } : null },
1115
+ async putSecret ({ scopeType, scopeId, name, sealedValue, keyVersion, digest, upstream, format, who, by }) {
1116
+ if (!secretDataKeys.has(`${scopeKey(scopeType, scopeId)}\n${keyVersion}`)) throw fkViolation('that data key', 'does not exist')
1117
+ const had = all(secretRows, (r) => r.scopeType === scopeType && r.scopeId === scopeId && r.name === name)[0]
1118
+ const at = now()
1119
+ if (had) { Object.assign(had, { sealedValue, keyVersion, digest, upstream, format: copy(format), who, updatedBy: by, updatedAt: at }); return secretMeta(had) }
1120
+ const row = { id: uuid(), scopeType, scopeId, name, sealedValue, keyVersion, digest, upstream, format: copy(format), who, createdBy: by, updatedBy: by, createdAt: at, updatedAt: at, lastUsedAt: null }
1121
+ secretRows.set(row.id, row)
1122
+ return secretMeta(row)
1123
+ },
1124
+ async updateSecret (id, { who, updatedBy }) {
1125
+ const r = secretRows.get(id)
1126
+ if (!r) return null
1127
+ Object.assign(r, { who, updatedBy, updatedAt: now() })
1128
+ return secretMeta(r)
1129
+ },
1130
+ async deleteSecret (id) {
1131
+ for (const [k, a] of secretAsks) if (a.secretId === id) secretAsks.delete(k)
1132
+ for (let i = secretUses.length - 1; i >= 0; i--) if (secretUses[i].secretId === id) secretUses.splice(i, 1)
1133
+ return secretRows.delete(id)
1134
+ },
1135
+ async deleteScopeSecrets (scopeType, scopeId) {
1136
+ for (const r of all(secretRows, (x) => x.scopeType === scopeType && x.scopeId === scopeId)) await this.deleteSecret(r.id)
1137
+ for (const k of [...secretDataKeys.keys()]) if (k.startsWith(`${scopeKey(scopeType, scopeId)}\n`)) secretDataKeys.delete(k)
1138
+ secretSettingsRows.delete(scopeKey(scopeType, scopeId))
1139
+ },
1140
+ async secretDataKey (scopeType, scopeId) {
1141
+ return copy(all(secretDataKeys, (k) => k.scopeType === scopeType && k.scopeId === scopeId).sort((a, b) => b.version - a.version)[0] || null)
1142
+ },
1143
+ async secretDataKeyVersion (scopeType, scopeId, version) { return copy(secretDataKeys.get(`${scopeKey(scopeType, scopeId)}\n${version}`) || null) },
1144
+ /** Stores a data key unless that version is already there; answers the one stored. */
1145
+ async putSecretDataKey ({ scopeType, scopeId, version, sealedKey, provider }) {
1146
+ const k = `${scopeKey(scopeType, scopeId)}\n${version}`
1147
+ if (!secretDataKeys.has(k)) secretDataKeys.set(k, { scopeType, scopeId, version, sealedKey, provider, createdAt: now() })
1148
+ return copy(secretDataKeys.get(k))
1149
+ },
1150
+ async secretSettings (scopeType, scopeId) {
1151
+ return copy(secretSettingsRows.get(scopeKey(scopeType, scopeId))) || { scopeType, scopeId, inheritOff: false, inheritOffBy: null, inheritOffAt: null, agentsOn: false, agents: 'all', updatedAt: null }
1152
+ },
1153
+ async putSecretSettings (scopeType, scopeId, patch) {
1154
+ const row = { ...(await this.secretSettings(scopeType, scopeId)), ...patch, scopeType, scopeId, updatedAt: now() }
1155
+ secretSettingsRows.set(scopeKey(scopeType, scopeId), copy(row))
1156
+ return copy(row)
1157
+ },
1158
+ async createSecretTicket ({ tokenHash, scopeType, scopeId, entries, createdBy, expiresAt }) {
1159
+ const t = { id: uuid(), tokenHash, scopeType, scopeId, entries: copy(entries), createdBy, createdAt: now(), expiresAt, redeemedAt: null, storedAt: null }
1160
+ secretTickets.set(t.id, t)
1161
+ return copy(t)
1162
+ },
1163
+ async redeemSecretTicket (tokenHash, at) {
1164
+ const t = all(secretTickets, (x) => x.tokenHash === tokenHash)[0]
1165
+ if (!t || t.redeemedAt || t.expiresAt <= at) return null
1166
+ t.redeemedAt = at
1167
+ return copy(t)
1168
+ },
1169
+ async secretTicketById (id) { return copy(secretTickets.get(id)) },
1170
+ async markSecretTicketStored (id, at) {
1171
+ const t = secretTickets.get(id)
1172
+ if (!t || !t.redeemedAt || t.storedAt) return false
1173
+ t.storedAt = at
1174
+ return true
1175
+ },
1176
+ async pruneSecretTickets (before) { for (const [k, t] of secretTickets) if (t.expiresAt < before) secretTickets.delete(k) },
1177
+ async createSecretRun ({ tokenHash, room, account, kind, label = '', expiresAt }) {
1178
+ const r = { id: uuid(), tokenHash, room, account, kind, label, createdAt: now(), expiresAt, revokedAt: null }
1179
+ secretRuns.set(r.id, r)
1180
+ return copy(r)
1181
+ },
1182
+ async secretRunByToken (tokenHash) { return copy(all(secretRuns, (r) => r.tokenHash === tokenHash)[0]) },
1183
+ async secretRunById (id) { return copy(secretRuns.get(id)) },
1184
+ async revokeSecretRun (id, at) { const r = secretRuns.get(id); if (!r || r.revokedAt) return false; r.revokedAt = at; return true },
1185
+ async putSecretRunAsks (runId, asks) {
1186
+ for (const a of asks) secretAsks.set(`${runId}\n${a.secretId}`, { runId, secretId: a.secretId, name: a.name, state: a.state || 'asked', askedAt: now(), answeredAt: null })
1187
+ },
1188
+ async listSecretRunAsks (runId) { return all(secretAsks, (a) => a.runId === runId).map(copy) },
1189
+ async answerSecretRunAsks (runId, state, at) {
1190
+ let n = 0
1191
+ for (const a of secretAsks.values()) if (a.runId === runId && a.state === 'asked') { Object.assign(a, { state, answeredAt: at }); n++ }
1192
+ return n
1193
+ },
1194
+ /** A person's runs still waiting for them to allow people-only secrets: [{ run, asks }]. */
1195
+ async pendingSecretAsks (account, at) {
1196
+ const out = []
1197
+ for (const r of secretRuns.values()) {
1198
+ if (r.account !== account || r.revokedAt || r.expiresAt <= at) continue
1199
+ const asks = all(secretAsks, (a) => a.runId === r.id && a.state === 'asked').map(copy)
1200
+ if (asks.length) out.push({ run: copy(r), asks })
1201
+ }
1202
+ return out.sort((a, b) => a.run.createdAt - b.run.createdAt)
1203
+ },
1204
+ async recordSecretUse ({ secretId, runId, room, account, upstream, at }) {
1205
+ const r = secretRows.get(secretId)
1206
+ if (!r) return
1207
+ r.lastUsedAt = at
1208
+ secretUses.push({ id: secretUses.length + 1, secretId, runId, room, account, upstream, at })
1209
+ },
1210
+ async listSecretUses (secretIds, { limit = 100 } = {}) {
1211
+ return secretUses.filter((u) => secretIds.includes(u.secretId)).sort((a, b) => b.at - a.at).slice(0, limit).map(copy)
1212
+ },
1213
+
1078
1214
  // Test-only views (production reads the tables in the Supabase dashboard).
1079
1215
  listEvents () { return [...events.values()].map(copy) },
1080
1216
  listIssues () { return [...issues.values()].map(copy) },