heyquilt 0.0.1 → 0.3.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// The relay on many machines (docs/superpowers/specs/2026-10-17-relay-scaling.md). Each room lives
|
|
2
|
+
// on one machine; the accounts API says which (src/api/relay-cluster.js). A machine that gets a
|
|
3
|
+
// request for a room it does not hold answers `fly-replay: instance=<machine>` and Fly's proxy
|
|
4
|
+
// sends the request there instead; a body too big for Fly to replay (over 1 MB, or of unknown
|
|
5
|
+
// length) is streamed to that machine over Fly's private network. The API reaches one machine
|
|
6
|
+
// with `fly-force-instance-id`. Machines say hello when they start and report every minute.
|
|
7
|
+
//
|
|
8
|
+
// Off unless QUILT_CLUSTER=1: one machine, as before. On, it needs the machine id (FLY_MACHINE_ID),
|
|
9
|
+
// the accounts API (QUILT_API_URL, RELAY_API_SECRET), sign-in, and with git sync the one file key
|
|
10
|
+
// every machine shares (QUILT_RELAY_FILE_KEY).
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import http from 'node:http'
|
|
14
|
+
import https from 'node:https'
|
|
15
|
+
|
|
16
|
+
// A room placed elsewhere is asked about again after this; one placed here stays here until the API moves it.
|
|
17
|
+
export const PLACEMENT_TTL_MS = 30 * 1000
|
|
18
|
+
// Fly's proxy replays a request's body up to this size.
|
|
19
|
+
export const REPLAY_MAX_BODY = 1024 * 1024
|
|
20
|
+
export const REPORT_MS = 60 * 1000
|
|
21
|
+
// A hosted agent's sessions and webhook from the API, kept this long.
|
|
22
|
+
export const INDEX_TTL_MS = 30 * 1000
|
|
23
|
+
export const SAME_OWNER_TTL_MS = 5 * 60 * 1000
|
|
24
|
+
const API_TIMEOUT_MS = 10 * 1000
|
|
25
|
+
const ROOM_RE = /^[A-Za-z0-9_-]{1,64}$/
|
|
26
|
+
const MACHINE_RE = /^[A-Za-z0-9_-]{1,64}$/
|
|
27
|
+
// Files in the data folder that look like rooms (`<name>.json`) and aren't.
|
|
28
|
+
export const NOT_ROOMS = new Set(['agent-links', 'hosted-agents', 'leases'])
|
|
29
|
+
export const MOVING = 'This session is moving to another relay machine; try again in a moment'
|
|
30
|
+
export const ELSEWHERE = 'This session lives on another relay machine; try again in a moment'
|
|
31
|
+
export const NO_PLACEMENT = "The relay can't find which machine holds this session right now; try again in a moment"
|
|
32
|
+
|
|
33
|
+
/** Cluster settings, from options or the environment (Fly sets FLY_MACHINE_ID, FLY_REGION, FLY_APP_NAME). */
|
|
34
|
+
export function clusterConfig (opts = {}, env = process.env) {
|
|
35
|
+
const on = opts.cluster ?? /^(1|true|yes)$/i.test(String(env.QUILT_CLUSTER || ''))
|
|
36
|
+
const app = env.FLY_APP_NAME || ''
|
|
37
|
+
const port = env.PORT || '4321'
|
|
38
|
+
const capacity = Number(opts.capacity ?? env.QUILT_RELAY_CAPACITY ?? 50)
|
|
39
|
+
return {
|
|
40
|
+
on: !!on,
|
|
41
|
+
machineId: String(opts.machineId ?? env.FLY_MACHINE_ID ?? ''),
|
|
42
|
+
region: String(opts.region ?? env.FLY_REGION ?? ''),
|
|
43
|
+
capacity: Number.isInteger(capacity) && capacity > 0 ? capacity : 50,
|
|
44
|
+
// Another machine of this app, over Fly's private network; with no {id} in it, the address is
|
|
45
|
+
// used as it is, with fly-force-instance-id (a proxy that honours it, as tests use).
|
|
46
|
+
peerUrl: String(opts.peerUrl ?? env.QUILT_PEER_URL ?? (app ? `http://{id}.vm.${app}.internal:${port}` : '')),
|
|
47
|
+
reportMs: Number(opts.reportMs ?? env.QUILT_CLUSTER_REPORT_MS) > 0 ? Number(opts.reportMs ?? env.QUILT_CLUSTER_REPORT_MS) : REPORT_MS,
|
|
48
|
+
placementTtlMs: Number(opts.placementTtlMs) > 0 ? Number(opts.placementTtlMs) : PLACEMENT_TTL_MS
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What cluster mode is missing; [] when it can run. */
|
|
53
|
+
export function clusterProblems (cc, cfg, { fileKey = '' } = {}) {
|
|
54
|
+
if (!cc.on) return []
|
|
55
|
+
const out = []
|
|
56
|
+
if (!MACHINE_RE.test(cc.machineId)) out.push('FLY_MACHINE_ID (this machine\'s id)')
|
|
57
|
+
if (!cfg.apiUrl) out.push('QUILT_API_URL')
|
|
58
|
+
if (!cfg.relayApiSecret) out.push('RELAY_API_SECRET')
|
|
59
|
+
if (!cfg.passPublicKey) out.push('QUILT_PASS_PUBLIC_KEY (sign-in)')
|
|
60
|
+
if (cfg.gitSync && !fileKey) out.push('QUILT_RELAY_FILE_KEY (the file key every machine shares)')
|
|
61
|
+
if (!cc.peerUrl) out.push('QUILT_PEER_URL or FLY_APP_NAME (how machines reach each other)')
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The rooms kept in a data folder (their `.json` or `.ydoc`). */
|
|
66
|
+
export function storedRooms (dataDir) {
|
|
67
|
+
if (!dataDir) return []
|
|
68
|
+
const out = new Set()
|
|
69
|
+
let files = []
|
|
70
|
+
try { files = fs.readdirSync(dataDir) } catch { return [] }
|
|
71
|
+
for (const f of files) {
|
|
72
|
+
const m = /^(.+)\.(json|ydoc)$/.exec(f)
|
|
73
|
+
if (m && ROOM_RE.test(m[1]) && !NOT_ROOMS.has(m[1])) out.add(m[1])
|
|
74
|
+
}
|
|
75
|
+
return [...out].sort()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The room a request's path is about ('' for none): git, chat files, large files, chat links. */
|
|
79
|
+
export function roomOfPath (pathname) {
|
|
80
|
+
const m = /^\/git\/([A-Za-z0-9_-]{1,64})\.git\//.exec(pathname) ||
|
|
81
|
+
/^\/files\/([A-Za-z0-9_-]{1,64})(?:\/|$)/.exec(pathname) ||
|
|
82
|
+
/^\/blobs\/([A-Za-z0-9_-]{1,64})\//.exec(pathname) ||
|
|
83
|
+
/^\/c\/([A-Za-z0-9_-]{1,64})\//.exec(pathname)
|
|
84
|
+
return m ? m[1] : ''
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The machine a request was already sent on from (Fly's replay, or another machine's forward), or ''. */
|
|
88
|
+
export function replaySource (req) {
|
|
89
|
+
const fly = /(?:^|;)\s*instance=([A-Za-z0-9_-]+)/.exec(String(req.headers['fly-replay-src'] || ''))
|
|
90
|
+
if (fly) return fly[1]
|
|
91
|
+
const fwd = String(req.headers['x-quilt-forwarded'] || '')
|
|
92
|
+
return MACHINE_RE.test(fwd) ? fwd : ''
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether Fly can replay this request (its body, if any, is known to be at most 1 MB). */
|
|
96
|
+
export function canReplay (req, readBody = null) {
|
|
97
|
+
if (readBody) return readBody.length <= REPLAY_MAX_BODY
|
|
98
|
+
if (req.method === 'GET' || req.method === 'HEAD') return true
|
|
99
|
+
if (req.headers['transfer-encoding']) return false
|
|
100
|
+
const n = Number(req.headers['content-length'] || 0)
|
|
101
|
+
return Number.isFinite(n) && n <= REPLAY_MAX_BODY
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class Cluster {
|
|
105
|
+
constructor ({ cc, apiUrl, secret, fetch = globalThis.fetch, log = () => {}, dataDir = null, now = Date.now }) {
|
|
106
|
+
this.cc = cc
|
|
107
|
+
this.machineId = cc.machineId
|
|
108
|
+
this.api = String(apiUrl).replace(/\/+$/, '')
|
|
109
|
+
this.secret = secret
|
|
110
|
+
this.fetch = fetch
|
|
111
|
+
this.log = log
|
|
112
|
+
this.dataDir = dataDir
|
|
113
|
+
this.now = now
|
|
114
|
+
this.here = new Set() // rooms the API placed on this machine
|
|
115
|
+
this.elsewhere = new Map() // room -> { machine, at }
|
|
116
|
+
this.moving = new Set() // rooms being moved off this machine: refused until forgotten or restored
|
|
117
|
+
this.hosted = new Map() // account -> { rooms, webhook, at }
|
|
118
|
+
this.owners = new Map() // `${room}\n${account}` -> { same, at }
|
|
119
|
+
this.hello = null // the promise of this start's hello
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async call (method, p, body) {
|
|
123
|
+
const res = await this.fetch(`${this.api}${p}`, {
|
|
124
|
+
method,
|
|
125
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${this.secret}` },
|
|
126
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
127
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS)
|
|
128
|
+
})
|
|
129
|
+
if (!res.ok) throw new Error(`the accounts API answered ${res.status}`)
|
|
130
|
+
return res.json()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
onDisk (room) {
|
|
134
|
+
if (!this.dataDir) return false
|
|
135
|
+
return fs.existsSync(path.join(this.dataDir, `${room}.json`)) || fs.existsSync(path.join(this.dataDir, `${room}.ydoc`))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Says hello (tried again every 30 s until the API answers): the rooms on this disk; returns those placed elsewhere. */
|
|
139
|
+
start ({ onDisplaced = () => {}, retryMs = 30 * 1000 } = {}) {
|
|
140
|
+
let timer = null
|
|
141
|
+
const once = async () => {
|
|
142
|
+
const rooms = storedRooms(this.dataDir)
|
|
143
|
+
try {
|
|
144
|
+
const r = await this.call('POST', '/v1/relay/machines/hello', { machine: this.machineId, region: this.cc.region, capacity: this.cc.capacity, rooms })
|
|
145
|
+
const displaced = new Set(Array.isArray(r.displaced) ? r.displaced : [])
|
|
146
|
+
for (const room of rooms) if (!displaced.has(room)) this.here.add(room)
|
|
147
|
+
if (displaced.size) onDisplaced([...displaced])
|
|
148
|
+
this.log(`cluster: machine ${this.machineId} holds ${rooms.length - displaced.size} stored room(s)`)
|
|
149
|
+
return true
|
|
150
|
+
} catch (err) {
|
|
151
|
+
this.log(`cluster: could not say hello to the accounts API (${err.message}); trying again in ${Math.round(retryMs / 1000)} s`)
|
|
152
|
+
await new Promise((resolve) => { timer = setTimeout(resolve, retryMs); timer.unref?.() })
|
|
153
|
+
return this.closed ? false : once()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
this.hello = once()
|
|
157
|
+
this.stopHello = () => clearTimeout(timer)
|
|
158
|
+
return this.hello
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
stop () { this.closed = true; if (this.stopHello) this.stopHello() }
|
|
162
|
+
|
|
163
|
+
/** This machine's minute, for the API's placement and heavy rooms. */
|
|
164
|
+
async report (load, rooms) {
|
|
165
|
+
return this.call('POST', '/v1/relay/machines/report', { machine: this.machineId, load, rooms })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Whether this machine holds `room` (and may load it). */
|
|
169
|
+
holds (room) { return this.here.has(room) && !this.moving.has(room) }
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Where `room` lives: { here } | { machine } | { moving } | { none } | { error }. `fresh` skips
|
|
173
|
+
* the cache of rooms placed elsewhere (a request replayed here that this machine doesn't hold);
|
|
174
|
+
* without `create` (nobody signed in asked) a room placed nowhere is { none } and stays unplaced.
|
|
175
|
+
*/
|
|
176
|
+
async locate (room, { fresh = false, create = true } = {}) {
|
|
177
|
+
if (this.moving.has(room)) return { moving: true }
|
|
178
|
+
if (this.here.has(room)) return { here: true }
|
|
179
|
+
const c = this.elsewhere.get(room)
|
|
180
|
+
if (!fresh && c && this.now() - c.at < this.cc.placementTtlMs) return { machine: c.machine }
|
|
181
|
+
const have = this.onDisk(room)
|
|
182
|
+
let r
|
|
183
|
+
try {
|
|
184
|
+
r = await this.call('POST', '/v1/relay/placement', { room, machine: this.machineId, have, create })
|
|
185
|
+
} catch (err) {
|
|
186
|
+
// The API is down: what is on this disk is this machine's (moves delete the old copy); nothing else is.
|
|
187
|
+
if (have) { this.log(`cluster: placement unavailable (${err.message}); serving ${room} from this disk`); this.here.add(room); return { here: true } }
|
|
188
|
+
this.log(`cluster: placement unavailable for ${room} (${err.message})`)
|
|
189
|
+
return { error: NO_PLACEMENT }
|
|
190
|
+
}
|
|
191
|
+
if (r.state === 'moving') return { moving: true }
|
|
192
|
+
if (r.machine === null && r.state === 'none') return { none: true }
|
|
193
|
+
if (r.machine === this.machineId) { this.here.add(room); this.elsewhere.delete(room); return { here: true } }
|
|
194
|
+
if (!MACHINE_RE.test(String(r.machine || ''))) return { error: NO_PLACEMENT }
|
|
195
|
+
this.elsewhere.set(room, { machine: r.machine, at: this.now() })
|
|
196
|
+
if (this.elsewhere.size > 10000) this.elsewhere.delete(this.elsewhere.keys().next().value)
|
|
197
|
+
return { machine: r.machine }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The room is leaving this machine: refused from now on. */
|
|
201
|
+
release (room) { this.moving.add(room); this.here.delete(room) }
|
|
202
|
+
/** The move failed: the room is this machine's again. */
|
|
203
|
+
restore (room) { this.moving.delete(room); this.here.add(room) }
|
|
204
|
+
/** The room lives on `to` now (or nowhere: deleted). */
|
|
205
|
+
forget (room, to = '') {
|
|
206
|
+
this.moving.delete(room)
|
|
207
|
+
this.here.delete(room)
|
|
208
|
+
if (to && MACHINE_RE.test(to)) this.elsewhere.set(room, { machine: to, at: this.now() }); else this.elsewhere.delete(room)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A room deleted for good (its owner ended it and it was swept, or idle past its days): no longer placed. */
|
|
212
|
+
forgetPlacement (room) {
|
|
213
|
+
this.forget(room)
|
|
214
|
+
this.call('POST', '/v1/relay/placement/forget', { room, machine: this.machineId }).catch((err) => this.log(`cluster: could not forget ${room}'s placement: ${err.message}`))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** How to reach machine `id`: { url, headers }. */
|
|
218
|
+
peer (id) {
|
|
219
|
+
const t = this.cc.peerUrl.replace(/\/+$/, '')
|
|
220
|
+
return t.includes('{id}') ? { url: t.replaceAll('{id}', id), headers: {} } : { url: t, headers: { 'fly-force-instance-id': id } }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Streams a request on to machine `id` (`body`: already read, else the request's own stream), and its answer back. */
|
|
224
|
+
forward (req, res, id, body = null) {
|
|
225
|
+
const p = this.peer(id)
|
|
226
|
+
const u = new URL(req.url, p.url)
|
|
227
|
+
const lib = u.protocol === 'https:' ? https : http
|
|
228
|
+
const headers = { ...req.headers, ...p.headers, 'x-quilt-forwarded': this.machineId, host: u.host }
|
|
229
|
+
if (body) { delete headers['transfer-encoding']; headers['content-length'] = String(body.length) }
|
|
230
|
+
const out = lib.request(u, { method: req.method, headers }, (back) => {
|
|
231
|
+
res.writeHead(back.statusCode || 502, back.headers)
|
|
232
|
+
back.pipe(res)
|
|
233
|
+
})
|
|
234
|
+
out.on('error', (err) => {
|
|
235
|
+
this.log(`cluster: could not reach machine ${id}: ${err.message}`)
|
|
236
|
+
if (!res.headersSent) { res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8', 'retry-after': '5' }); res.end(ELSEWHERE) } else res.destroy()
|
|
237
|
+
})
|
|
238
|
+
if (body) out.end(body); else req.pipe(out)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Another machine's own data (an export), with the relay secret. */
|
|
242
|
+
fromPeer (id, p) {
|
|
243
|
+
const peer = this.peer(id)
|
|
244
|
+
const u = new URL(p, peer.url)
|
|
245
|
+
const lib = u.protocol === 'https:' ? https : http
|
|
246
|
+
return new Promise((resolve, reject) => {
|
|
247
|
+
const r = lib.request(u, { method: 'GET', headers: { ...peer.headers, authorization: `Bearer ${this.secret}` } }, (res) => {
|
|
248
|
+
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`machine ${id} answered ${res.statusCode}`)) }
|
|
249
|
+
resolve(res)
|
|
250
|
+
})
|
|
251
|
+
r.on('error', reject)
|
|
252
|
+
r.end()
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---- hosted agents across machines: the account's sessions and webhook, kept by the API ----
|
|
257
|
+
|
|
258
|
+
/** { rooms, webhook } for `account` from the API (cached), or null when it can't be asked. */
|
|
259
|
+
async hostedOf (account, { fresh = false } = {}) {
|
|
260
|
+
const c = this.hosted.get(account)
|
|
261
|
+
if (!fresh && c && this.now() - c.at < INDEX_TTL_MS) return c
|
|
262
|
+
try {
|
|
263
|
+
const r = await this.call('GET', `/v1/relay/hosted/${encodeURIComponent(account)}`)
|
|
264
|
+
const v = { rooms: Array.isArray(r.rooms) ? r.rooms.filter((x) => ROOM_RE.test(x)) : [], webhook: r.webhook || null, at: this.now() }
|
|
265
|
+
this.hosted.set(account, v)
|
|
266
|
+
if (this.hosted.size > 10000) this.hosted.delete(this.hosted.keys().next().value)
|
|
267
|
+
return v
|
|
268
|
+
} catch (err) {
|
|
269
|
+
this.log(`cluster: could not read ${account}'s sessions (${err.message})`)
|
|
270
|
+
return c || null
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The account joined (`add`) or left (`remove`) a session on this machine. */
|
|
275
|
+
async hostedRoom (account, { add, remove }) {
|
|
276
|
+
const c = this.hosted.get(account)
|
|
277
|
+
if (c) {
|
|
278
|
+
if (add && !c.rooms.includes(add)) c.rooms.push(add)
|
|
279
|
+
if (remove) c.rooms = c.rooms.filter((r) => r !== remove)
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const r = await this.call('POST', `/v1/relay/hosted/${encodeURIComponent(account)}/rooms`, { ...(add ? { add } : {}), ...(remove ? { remove } : {}) })
|
|
283
|
+
if (c && Array.isArray(r.rooms)) c.rooms = r.rooms
|
|
284
|
+
} catch (err) { this.log(`cluster: could not note ${account}'s sessions (${err.message})`) }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async hostedWebhook (account, webhook) {
|
|
288
|
+
const c = this.hosted.get(account)
|
|
289
|
+
if (c) c.webhook = webhook || { off: true }
|
|
290
|
+
try { await this.call('PUT', `/v1/relay/hosted/${encodeURIComponent(account)}/webhook`, { webhook }) } catch (err) { this.log(`cluster: could not keep ${account}'s webhook (${err.message})`) }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Whether `account` owns session `room` or has been in it, from the API (cached): true/false,
|
|
295
|
+
* or undefined while it is being asked (then `then` runs with the answer).
|
|
296
|
+
*/
|
|
297
|
+
sameOwner (room, account, then = () => {}) {
|
|
298
|
+
const k = `${room}\n${account}`
|
|
299
|
+
const c = this.owners.get(k)
|
|
300
|
+
if (c && this.now() - c.at < SAME_OWNER_TTL_MS) return c.same
|
|
301
|
+
if (!c || !c.asking) {
|
|
302
|
+
const entry = { same: c ? c.same : undefined, at: c ? c.at : 0, asking: true }
|
|
303
|
+
this.owners.set(k, entry)
|
|
304
|
+
this.call('POST', '/v1/relay/same-owner', { room, account })
|
|
305
|
+
.then((r) => { const same = r.same === true; const changed = same !== entry.same; Object.assign(entry, { same, at: this.now(), asking: false }); if (changed) then(same) })
|
|
306
|
+
.catch((err) => { entry.asking = false; this.log(`cluster: could not ask who is in ${room} (${err.message})`) })
|
|
307
|
+
if (this.owners.size > 10000) this.owners.delete(this.owners.keys().next().value)
|
|
308
|
+
}
|
|
309
|
+
return c ? c.same : undefined
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- a room's files, from one machine to another ----
|
|
314
|
+
//
|
|
315
|
+
// The stream is frames: a JSON line ({ path, size }) and that many bytes; the last line is
|
|
316
|
+
// { end: true, files, bytes }. Paths are relative to the data folder and belong to the room.
|
|
317
|
+
|
|
318
|
+
/** The paths of the room's files, relative to the data folder. */
|
|
319
|
+
export function roomPaths (dataDir, room) {
|
|
320
|
+
const out = []
|
|
321
|
+
const walk = (rel) => {
|
|
322
|
+
const abs = path.join(dataDir, rel)
|
|
323
|
+
let st
|
|
324
|
+
try { st = fs.statSync(abs) } catch { return }
|
|
325
|
+
if (st.isDirectory()) { for (const f of fs.readdirSync(abs).sort()) walk(path.posix.join(rel, f)) } else if (st.isFile()) out.push(rel)
|
|
326
|
+
}
|
|
327
|
+
for (const rel of [`${room}.ydoc`, `${room}.json`, `${room}.chat.jsonl`, `branches/${room}`, `files/${room}`, `blobs/${room}`]) walk(rel)
|
|
328
|
+
return out
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Whether `rel` is one of `room`'s own paths (nothing else is written on import). */
|
|
332
|
+
export function roomOwns (room, rel) {
|
|
333
|
+
if (typeof rel !== 'string' || rel.includes('\\') || rel.split('/').some((p) => p === '..' || p === '.' || p === '')) return false
|
|
334
|
+
return rel === `${room}.ydoc` || rel === `${room}.json` || rel === `${room}.chat.jsonl` || [`branches/${room}/`, `files/${room}/`, `blobs/${room}/`].some((p) => rel.startsWith(p))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Writes room `room`'s files (and `extras`, JSON) to `res` as frames, a file at a time from disk. */
|
|
338
|
+
export async function packRoom (dataDir, room, extras, res) {
|
|
339
|
+
let files = 0
|
|
340
|
+
let bytes = 0
|
|
341
|
+
const write = (chunk) => new Promise((resolve, reject) => {
|
|
342
|
+
const fail = (err) => reject(err)
|
|
343
|
+
res.once('error', fail)
|
|
344
|
+
const done = () => { res.off('error', fail); resolve() }
|
|
345
|
+
if (res.write(chunk)) done(); else res.once('drain', done)
|
|
346
|
+
})
|
|
347
|
+
const x = Buffer.from(JSON.stringify(extras || {}))
|
|
348
|
+
await write(`${JSON.stringify({ path: ':extras', size: x.length })}\n`)
|
|
349
|
+
await write(x)
|
|
350
|
+
for (const rel of roomPaths(dataDir, room)) {
|
|
351
|
+
const abs = path.join(dataDir, rel)
|
|
352
|
+
const size = fs.statSync(abs).size
|
|
353
|
+
await write(`${JSON.stringify({ path: rel, size })}\n`)
|
|
354
|
+
let sent = 0
|
|
355
|
+
for await (const chunk of fs.createReadStream(abs, { end: Math.max(0, size - 1), highWaterMark: 1024 * 1024 })) {
|
|
356
|
+
if (!size) break
|
|
357
|
+
await write(chunk)
|
|
358
|
+
sent += chunk.length
|
|
359
|
+
}
|
|
360
|
+
if (sent !== size) throw new Error(`${rel} changed while it was being copied`)
|
|
361
|
+
files++
|
|
362
|
+
bytes += size
|
|
363
|
+
}
|
|
364
|
+
res.end(`${JSON.stringify({ end: true, files, bytes })}\n`)
|
|
365
|
+
return { files, bytes }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Reads frames from `stream` into `stage` (a fresh folder), checking each path belongs to `room`,
|
|
370
|
+
* writing each file as its bytes come. Resolves { files, bytes, extras } once the end line matches
|
|
371
|
+
* what came; rejects otherwise.
|
|
372
|
+
*/
|
|
373
|
+
export async function unpackRoom (stream, room, stage) {
|
|
374
|
+
fs.rmSync(stage, { recursive: true, force: true })
|
|
375
|
+
fs.mkdirSync(stage, { recursive: true })
|
|
376
|
+
let head = Buffer.alloc(0) // the start of a frame's line, until its newline comes
|
|
377
|
+
let want = null // { path, size, left, fd?, extras? }
|
|
378
|
+
let files = 0
|
|
379
|
+
let bytes = 0
|
|
380
|
+
let extras = {}
|
|
381
|
+
let end = null
|
|
382
|
+
const finish = () => {
|
|
383
|
+
if (want.path === ':extras') extras = JSON.parse(Buffer.concat(want.parts).toString('utf8') || '{}')
|
|
384
|
+
else { fs.closeSync(want.fd); files++; bytes += want.size }
|
|
385
|
+
want = null
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
for await (const chunk of stream) {
|
|
389
|
+
let buf = chunk
|
|
390
|
+
while (buf.length && !end) {
|
|
391
|
+
if (!want) {
|
|
392
|
+
const nl = buf.indexOf(10)
|
|
393
|
+
if (nl < 0) { head = Buffer.concat([head, buf]); if (head.length > 4096) throw new Error('a malformed frame'); buf = buf.subarray(buf.length); break }
|
|
394
|
+
const line = JSON.parse(Buffer.concat([head, buf.subarray(0, nl)]).toString('utf8'))
|
|
395
|
+
head = Buffer.alloc(0)
|
|
396
|
+
buf = buf.subarray(nl + 1)
|
|
397
|
+
if (line.end) { end = line; break }
|
|
398
|
+
if (!(Number.isInteger(line.size) && line.size >= 0)) throw new Error('a malformed frame')
|
|
399
|
+
if (line.path === ':extras') {
|
|
400
|
+
if (line.size > 16 * 1024 * 1024) throw new Error('a malformed frame')
|
|
401
|
+
want = { path: line.path, size: line.size, left: line.size, parts: [] }
|
|
402
|
+
} else {
|
|
403
|
+
if (!roomOwns(room, line.path)) throw new Error(`a file that isn't the room's: ${String(line.path).slice(0, 100)}`)
|
|
404
|
+
const abs = path.join(stage, line.path)
|
|
405
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
406
|
+
want = { path: line.path, size: line.size, left: line.size, fd: fs.openSync(abs, 'w') }
|
|
407
|
+
}
|
|
408
|
+
if (!want.left) finish()
|
|
409
|
+
continue
|
|
410
|
+
}
|
|
411
|
+
const part = buf.subarray(0, want.left)
|
|
412
|
+
buf = buf.subarray(part.length)
|
|
413
|
+
if (want.parts) want.parts.push(part); else fs.writeSync(want.fd, part)
|
|
414
|
+
want.left -= part.length
|
|
415
|
+
if (!want.left) finish()
|
|
416
|
+
}
|
|
417
|
+
if (end) break
|
|
418
|
+
}
|
|
419
|
+
} finally {
|
|
420
|
+
if (want && want.fd !== undefined) fs.closeSync(want.fd)
|
|
421
|
+
}
|
|
422
|
+
if (!end || want || end.files !== files || end.bytes !== bytes) throw new Error(`the copy was cut short (${files} of ${end ? end.files : '?'} files)`)
|
|
423
|
+
return { files, bytes, extras }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Moves a staged room into the data folder, replacing whatever copy of it was there. */
|
|
427
|
+
export function placeStaged (dataDir, room, stage) {
|
|
428
|
+
for (const rel of [`${room}.ydoc`, `${room}.json`, `${room}.chat.jsonl`, `branches/${room}`, `files/${room}`, `blobs/${room}`]) {
|
|
429
|
+
const from = path.join(stage, rel)
|
|
430
|
+
const to = path.join(dataDir, rel)
|
|
431
|
+
fs.rmSync(to, { recursive: true, force: true })
|
|
432
|
+
if (!fs.existsSync(from)) continue
|
|
433
|
+
fs.mkdirSync(path.dirname(to), { recursive: true })
|
|
434
|
+
fs.renameSync(from, to)
|
|
435
|
+
}
|
|
436
|
+
fs.rmSync(stage, { recursive: true, force: true })
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Deletes this machine's copy of a room that lives elsewhere now (never its stored large files elsewhere). */
|
|
440
|
+
export function removeLocalRoom (dataDir, room) {
|
|
441
|
+
for (const rel of [`${room}.ydoc`, `${room}.json`, `${room}.chat.jsonl`, `branches/${room}`, `files/${room}`, `blobs/${room}`]) fs.rmSync(path.join(dataDir, rel), { recursive: true, force: true })
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Moves a room this machine should not hold aside, to `displaced/` (never served, never deleted). */
|
|
445
|
+
export function displaceRoom (dataDir, room, at = Date.now()) {
|
|
446
|
+
const dest = path.join(dataDir, 'displaced', `${room}-${at}`)
|
|
447
|
+
for (const rel of [`${room}.ydoc`, `${room}.json`, `${room}.chat.jsonl`, `branches/${room}`, `files/${room}`]) {
|
|
448
|
+
const from = path.join(dataDir, rel)
|
|
449
|
+
if (!fs.existsSync(from)) continue
|
|
450
|
+
fs.mkdirSync(path.dirname(path.join(dest, rel)), { recursive: true })
|
|
451
|
+
fs.renameSync(from, path.join(dest, rel))
|
|
452
|
+
}
|
|
453
|
+
return dest
|
|
454
|
+
}
|