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.
- package/README.md +14 -11
- package/RELEASES.md +135 -2
- package/bin/quilt.js +45 -12
- package/package.json +3 -3
- package/src/account.js +28 -0
- package/src/agents/claude-code.js +11 -4
- package/src/agents/cursor-transcripts.js +11 -4
- package/src/ai-vm-signin.js +14 -2
- package/src/ai-vm.js +150 -8
- package/src/api/agent-auth.js +9 -3
- package/src/api/fly-machines.js +72 -15
- package/src/api/memory-store.js +142 -6
- package/src/api/relay-cluster.js +337 -43
- package/src/api/relay-machines.js +35 -3
- package/src/api/releases.js +87 -0
- package/src/api/routes/agents.js +111 -16
- package/src/api/routes/ai-vm.js +146 -19
- package/src/api/routes/orgs.js +3 -1
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/workspaces.js +8 -1
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +40 -9
- package/src/api/supabase-store.js +121 -11
- package/src/autowork.js +3 -15
- package/src/catchup.js +1 -1
- package/src/clash.js +10 -22
- package/src/control.js +18 -15
- package/src/fsutil.js +11 -1
- package/src/gitstate.js +29 -5
- package/src/hooks.js +1 -1
- package/src/mcp-proxy.js +7 -2
- package/src/mcp.js +22 -25
- package/src/merge3.js +94 -12
- package/src/pass-source.js +9 -2
- package/src/pathrules.js +7 -0
- package/src/relay-clash.js +101 -484
- package/src/relay-githttp.js +36 -7
- package/src/relay-mcp.js +19 -7
- package/src/relay-metrics.js +66 -1
- package/src/relay-outbound.js +154 -25
- package/src/relay-sync.js +133 -136
- package/src/relay-turn-sync.js +250 -51
- package/src/relay-upstream.js +49 -179
- package/src/releases.js +16 -9
- package/src/runner.js +3 -2
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +71 -44
- package/src/session.js +347 -262
- package/src/status.js +3 -5
- package/src/sync-words.js +1 -1
- package/src/turn-sync-plan.js +13 -10
- package/src/turn-sync.js +32 -11
- package/src/ui/agent-guide.js +4 -4
- package/src/ui/app.css +208 -23
- package/src/ui/app.js +4 -0
- package/src/ui/cloud-ai-model.js +3 -2
- package/src/ui/common.js +2 -0
- package/src/ui/fileview.js +28 -1
- package/src/ui/home.js +32 -33
- package/src/ui/loom.css +18 -2
- package/src/ui/loom.js +41 -8
- package/src/ui/rail-model.js +6 -9
- package/src/ui/releases.js +1 -1
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +47 -149
- package/src/ui/tree.js +23 -18
- package/src/ui/workspaces.js +5 -0
- package/src/ui-server.js +140 -28
- package/src/update-check.js +1 -1
- package/src/upstream.js +75 -38
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/src/api/agent-auth.js
CHANGED
|
@@ -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
|
-
/**
|
|
97
|
-
|
|
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
|
}
|
package/src/api/fly-machines.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/api/memory-store.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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) },
|