heyquilt 0.0.1 → 0.3.106
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 +633 -2
- package/RELEASES.md +862 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +849 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +591 -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 +187 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +177 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +110 -0
- package/src/ai-vm.js +522 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +136 -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 +133 -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 +1219 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +545 -0
- package/src/api/relay-machines.js +92 -0
- package/src/api/releases.js +87 -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 +252 -0
- package/src/api/routes/ai-vm.js +495 -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 +179 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/secrets.js +448 -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 +232 -0
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +654 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +875 -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 +375 -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 +128 -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 +248 -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 +97 -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 +924 -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 +45 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1258 -0
- package/src/merge3.js +240 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +183 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +134 -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 +214 -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 +166 -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 +1691 -0
- package/src/relay-metrics.js +140 -0
- package/src/relay-outbound.js +1273 -0
- package/src/relay-sync.js +716 -0
- package/src/relay-turn-sync.js +506 -0
- package/src/relay-upstream.js +804 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +138 -0
- package/src/report.js +129 -0
- package/src/runner.js +307 -0
- package/src/same-edit.js +18 -0
- 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 +4214 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7446 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +197 -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 +145 -0
- package/src/turn-sync.js +212 -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 +2494 -0
- package/src/ui/app.js +546 -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 +108 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +433 -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 +150 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1322 -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 +365 -0
- package/src/ui/loom.js +786 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/rail-model.js +75 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/schedule.js +156 -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 +3292 -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 +281 -0
- package/src/ui/workspaces.js +547 -0
- package/src/ui-server.js +1381 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +192 -0
- 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/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/pathrules.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Which paths are synced, and which are safe to accept from a peer. No Node
|
|
2
|
+
// APIs here: the web app uses the same rules as the CLI.
|
|
3
|
+
import ignore from 'ignore'
|
|
4
|
+
|
|
5
|
+
export const MAX_TEXT_BYTES = 2 * 1024 * 1024
|
|
6
|
+
export const MAX_BINARY_BYTES = 8 * 1024 * 1024
|
|
7
|
+
// Binary files this big or bigger are stored encrypted outside the session document.
|
|
8
|
+
export const LARGE_FILE_BYTES = 256 * 1024
|
|
9
|
+
// The most a stored file may be (the relay may allow less).
|
|
10
|
+
export const MAX_STORED_BINARY_BYTES = 100 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Where each person's app saves the files sent in the session's chat (session.js): only those
|
|
14
|
+
* who can see the message get the file, so the folder is never synced, committed or pushed.
|
|
15
|
+
*/
|
|
16
|
+
export const CHAT_FILES = 'Chat files'
|
|
17
|
+
|
|
18
|
+
// Never synced, regardless of .gitignore. .env files are excluded so secrets
|
|
19
|
+
// stay on each person's machine; Claude Code worktrees are whole private
|
|
20
|
+
// copies of the project.
|
|
21
|
+
export const ALWAYS_IGNORED = [
|
|
22
|
+
'.git', '.quilt', '.cowove', 'node_modules', '.DS_Store', 'Thumbs.db', '.claude/worktrees',
|
|
23
|
+
`/${CHAT_FILES}`,
|
|
24
|
+
// Each person's own Claude Code settings (permissions, Quilt's hooks) stay on their machine.
|
|
25
|
+
'.claude/settings.local.json',
|
|
26
|
+
// Build output and caches: large, machine-specific, and rebuilt by each person.
|
|
27
|
+
'.next', '.turbo', '.nuxt', '.svelte-kit', '.parcel-cache', '.vercel',
|
|
28
|
+
'.env', '.env.*', '!.env.example',
|
|
29
|
+
// A .quiltignore is each person's own list of what stays on their machine, so it stays there too.
|
|
30
|
+
'.quiltignore', '.cowoveignore',
|
|
31
|
+
'*.swp', '*.swo', '*~', '.#*'
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
/** Files whose patterns say what isn't synced, in the folder they sit in. */
|
|
35
|
+
export const IGNORE_FILES = ['.gitignore', '.quiltignore', '.cowoveignore']
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rewrites an ignore file found in subfolder `dir` so its patterns apply from
|
|
39
|
+
* the project root, the way git scopes a nested .gitignore to its folder.
|
|
40
|
+
*/
|
|
41
|
+
export function scopeIgnore (text, dir) {
|
|
42
|
+
if (!dir) return text
|
|
43
|
+
return text.split(/\r?\n/).map((line) => {
|
|
44
|
+
if (!line.trim() || line.startsWith('#')) return line
|
|
45
|
+
const neg = line.startsWith('!')
|
|
46
|
+
const body = neg ? line.slice(1) : line
|
|
47
|
+
// A slash anywhere but the end anchors the pattern to this folder.
|
|
48
|
+
const anchored = body.replace(/\/+\s*$/, '').includes('/')
|
|
49
|
+
const scoped = anchored ? `${dir}/${body.replace(/^\//, '')}` : `${dir}/**/${body}`
|
|
50
|
+
return (neg ? '!' : '') + scoped
|
|
51
|
+
}).join('\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* An ignore matcher from the built-ins plus the text of .gitignore / .quiltignore
|
|
56
|
+
* files. The built-ins are kept apart from the project patterns, so a shared
|
|
57
|
+
* .gitignore saying "!.env" can't make anyone's secrets sync.
|
|
58
|
+
*/
|
|
59
|
+
export function makeIgnore (texts = []) {
|
|
60
|
+
const builtin = ignore().add(ALWAYS_IGNORED)
|
|
61
|
+
const project = ignore()
|
|
62
|
+
for (const t of texts) if (t) project.add(t)
|
|
63
|
+
return {
|
|
64
|
+
add (text) { project.add(text); return this },
|
|
65
|
+
ignores (p) { return builtin.ignores(p) || project.ignores(p) }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isIgnored (ig, rel) {
|
|
70
|
+
if (!rel || rel === '.') return false
|
|
71
|
+
// Check every ancestor directory so "dist/" also excludes "dist/a/b.js".
|
|
72
|
+
const parts = rel.split('/')
|
|
73
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
74
|
+
const sub = parts.slice(0, i).join('/')
|
|
75
|
+
if (ig.ignores(sub)) return true
|
|
76
|
+
if (i < parts.length && ig.ignores(sub + '/')) return true
|
|
77
|
+
}
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Names Windows reserves for devices, with any extension: a member there could never write them.
|
|
82
|
+
const WINDOWS_DEVICE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
|
|
83
|
+
const utf8Length = (s) => new TextEncoder().encode(s).length
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validates a path received from a peer. Rejects anything that could escape
|
|
87
|
+
* the project folder or touch git internals, and names that some member's
|
|
88
|
+
* disk could not hold (a change nobody can write is a change nobody should
|
|
89
|
+
* share).
|
|
90
|
+
*/
|
|
91
|
+
export function isSafeRelPath (rel) {
|
|
92
|
+
if (typeof rel !== 'string' || !rel || rel.length > 1024) return false
|
|
93
|
+
if (rel.includes('\\') || rel.includes('\0')) return false
|
|
94
|
+
if (rel.startsWith('/') || /^[A-Za-z]:/.test(rel)) return false
|
|
95
|
+
const parts = rel.split('/')
|
|
96
|
+
for (const part of parts) {
|
|
97
|
+
if (part === '' || part === '.' || part === '..') return false
|
|
98
|
+
if (WINDOWS_DEVICE.test(part) || part.endsWith('.') || part.endsWith(' ')) return false
|
|
99
|
+
if (part.length > 255 || utf8Length(part) > 255) return false
|
|
100
|
+
}
|
|
101
|
+
const first = parts[0].toLowerCase()
|
|
102
|
+
if (first === '.git' || first === '.quilt' || first === '.cowove') return false
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether two claim patterns can cover the same file (paths: files that exist now). */
|
|
107
|
+
export function patternsOverlap (a, b, paths = []) {
|
|
108
|
+
if (a === b) return true
|
|
109
|
+
const ma = globMatcher(a)
|
|
110
|
+
const mb = globMatcher(b)
|
|
111
|
+
return ma(b) || mb(a) || paths.some((p) => ma(p) && mb(p))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Converts a simple glob (*, **, ?) or a plain path/folder into a matcher. */
|
|
115
|
+
export function globMatcher (pattern) {
|
|
116
|
+
let p = pattern.trim().replace(/^\.\//, '')
|
|
117
|
+
if (!/[*?]/.test(p)) {
|
|
118
|
+
const base = p.replace(/\/+$/, '')
|
|
119
|
+
return (rel) => rel === base || rel.startsWith(base + '/')
|
|
120
|
+
}
|
|
121
|
+
let re = ''
|
|
122
|
+
for (let i = 0; i < p.length; i++) {
|
|
123
|
+
const c = p[i]
|
|
124
|
+
if (c === '*' && p[i + 1] === '*') {
|
|
125
|
+
re += '.*'
|
|
126
|
+
i++
|
|
127
|
+
if (p[i + 1] === '/') i++
|
|
128
|
+
} else if (c === '*') re += '[^/]*'
|
|
129
|
+
else if (c === '?') re += '[^/]'
|
|
130
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
131
|
+
}
|
|
132
|
+
const rx = new RegExp(`^${re}$`)
|
|
133
|
+
return (rel) => rx.test(rel)
|
|
134
|
+
}
|
package/src/persona.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// AI sessions as their own members. Several AI sessions often work through one person's
|
|
2
|
+
// app at once (Claude Code in one window, Cursor in another, two Claude Code chats). Each
|
|
3
|
+
// gets a name of its own, after what it works on, so partners can tell them apart, message
|
|
4
|
+
// the right one, and see whose claims are whose: "Daniel · file-queue", "Daniel · hosted costs".
|
|
5
|
+
//
|
|
6
|
+
// The name is the person's first name and a label. The label comes from the session's git
|
|
7
|
+
// branch when it works on one (a branch names the work and doesn't change), otherwise from
|
|
8
|
+
// the first thing it says it is doing (its focus, what it shares, the task it takes), and
|
|
9
|
+
// the agent can rename itself. Until then it is named after its tool.
|
|
10
|
+
import { execFileSync } from 'node:child_process'
|
|
11
|
+
|
|
12
|
+
export const PERSONA_SEP = ' · '
|
|
13
|
+
const MAX_LABEL = 32
|
|
14
|
+
|
|
15
|
+
/** The person's first name: "Daniel Carmichael" -> "Daniel". */
|
|
16
|
+
export const firstName = (person) => String(person || '').trim().split(/\s+/)[0] || 'AI'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A label as it may appear in a name: one line, no separator or @, at most 32 characters
|
|
20
|
+
* (cut at a word). '' when nothing is left.
|
|
21
|
+
*/
|
|
22
|
+
export function cleanLabel (label) {
|
|
23
|
+
let s = String(label || '').replace(/[\r\n\t]+/g, ' ').replace(/[@·]/g, ' ').replace(/[`"'<>]/g, '').replace(/\s+/g, ' ').trim()
|
|
24
|
+
if (s.length > MAX_LABEL) {
|
|
25
|
+
const cut = s.slice(0, MAX_LABEL + 1)
|
|
26
|
+
s = (cut.lastIndexOf(' ') > 8 ? cut.slice(0, cut.lastIndexOf(' ')) : cut.slice(0, MAX_LABEL)).trim()
|
|
27
|
+
}
|
|
28
|
+
return s.replace(/[\s.,;:!?-]+$/, '')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** "Daniel · file-queue". */
|
|
32
|
+
export const personaName = (person, label) => `${firstName(person)}${PERSONA_SEP}${cleanLabel(label) || 'AI'}`
|
|
33
|
+
|
|
34
|
+
const PLAIN_BRANCHES = new Set(['main', 'master', 'trunk', 'develop', 'dev', 'head', ''])
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A label from a git branch, or '' for one that says nothing about the work (main, master,
|
|
38
|
+
* a detached HEAD). Tool prefixes ("claude/", "cursor/", "feature/") are dropped.
|
|
39
|
+
*/
|
|
40
|
+
export function labelFromBranch (branch) {
|
|
41
|
+
const b = String(branch || '').trim()
|
|
42
|
+
if (PLAIN_BRANCHES.has(b.toLowerCase())) return ''
|
|
43
|
+
const last = b.split('/').filter(Boolean).pop() || ''
|
|
44
|
+
return cleanLabel(last)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The git branch checked out in `dir`, or '' (not a repository, or git missing). */
|
|
48
|
+
export function gitBranch (dir) {
|
|
49
|
+
try {
|
|
50
|
+
// symbolic-ref also answers on a branch with no commits yet; a detached HEAD has no branch.
|
|
51
|
+
return execFileSync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).toString().trim()
|
|
52
|
+
} catch { return '' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const FILLER = new Set(['i', 'im', 'i\'m', 'am', 'is', 'are', 'was', 'the', 'a', 'an', 'to', 'for', 'on', 'of', 'and', 'or', 'my', 'our', 'me', 'now', 'just', 'currently', 'working', 'work', 'going', 'will', 'be', 'about', 'with', 'in', 'this', 'that', 'these', 'it', 'its', 'please', 'can', 'could', 'would', 'should', 'you', 'your', 'we', 'let', 'lets', 'let\'s', 'start', 'starting', 'why', 'what', 'how', 'when', 'where', 'there', 'some', 'so', 'do', 'does', 'did', 'have', 'has', 'hey', 'hi', 'ok', 'okay', 'yes', 'no', 'all', 'also', 'into', 'at', 'from', 'by', 'up', 'out', 'make', 'sure', 'want', 'need', 'like', 'use', 'using', 'thanks', 'thank', 'their', 'his', 'her', 'them', 'they', 'he', 'she', 'if', 'then', 'but', 'than', 'which', 'who', 'while', 'very', 'really', 'something', 'anything', 'everything', 'thing', 'things'])
|
|
56
|
+
|
|
57
|
+
// An opener that tells what happened before the ask, not what the session does ("I just had gpt
|
|
58
|
+
// add some info and double check your plan", "we tried the fix and it broke"): the label comes
|
|
59
|
+
// from the first clause after it.
|
|
60
|
+
const NARRATION = new Set(['had', 'have', 'has', 'was', 'were', 'did', 'got', 'asked', 'told', 'made', 'tried', 'ran', 'saw', 'noticed', 'found', 'used', 'wanted', 'went', 'gave', 'sent', 'said', 'says', 'thought', 'think', 'thinking', 'added', 'wrote', 'changed', 'pulled', 'pushed', 'deployed', 'merged', 'been', 'being', 'already', 'earlier', 'yesterday', 'before', 'previously', 'since', 'remember'])
|
|
61
|
+
// Another AI or tool the opener names ("had gpt add", "asked claude to"): not this session's work.
|
|
62
|
+
const TOOL_NAMES = /^(?:(?:chat)?gpt[-\w.]*|claude|codex|cursor|gemini|grok|copilot|openai|anthropic|ai|bot|agent|assistant|llm|model|o[1-9](?:-\w+)?)$/i
|
|
63
|
+
// Where one clause ends and the next begins.
|
|
64
|
+
const CLAUSE = /[.!?;:\n]+|,\s+|\s+(?:and|then|but|so|now)\s+/i
|
|
65
|
+
|
|
66
|
+
/** The words of `text` that carry meaning, in order, lower case. */
|
|
67
|
+
function meaningfulWords (text) {
|
|
68
|
+
return String(text || '').split(/\s+/).filter((w) => w && !/^(https?:|\/|~|<)/.test(w))
|
|
69
|
+
.map((w) => w.replace(/[^\p{L}\p{N}'-]/gu, '').replace(/^['-]+|['-]+$/g, '')).filter(Boolean)
|
|
70
|
+
.filter((w) => !FILLER.has(w.toLowerCase()) && w.length <= 24).map((w) => w.toLowerCase())
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A label from what a session says it is doing (or was asked): its first few words that carry
|
|
75
|
+
* meaning, after any opener about what happened before ("I just had gpt add some info and
|
|
76
|
+
* double check your plan" -> "double check plan").
|
|
77
|
+
*/
|
|
78
|
+
export function labelFromText (text) {
|
|
79
|
+
const clauses = String(text || '').split(CLAUSE).map((c) => c.trim()).filter(Boolean)
|
|
80
|
+
const words = clauses.map(meaningfulWords)
|
|
81
|
+
let from = words.findIndex((w) => w.length && !NARRATION.has(w[0]))
|
|
82
|
+
let first
|
|
83
|
+
if (from >= 0) first = words[from]
|
|
84
|
+
else {
|
|
85
|
+
// Only narration: what was done, without the opener and the tool it names.
|
|
86
|
+
from = words.findIndex((w) => w.length)
|
|
87
|
+
if (from < 0) return ''
|
|
88
|
+
first = words[from].slice(1)
|
|
89
|
+
if (first.length && TOOL_NAMES.test(first[0])) first = first.slice(1)
|
|
90
|
+
}
|
|
91
|
+
return cleanLabel([...first, ...words.slice(from + 1).flat()].slice(0, 3).join(' '))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A label from the first file a session edits: its name without the extension ("session.js" -> "session"). */
|
|
95
|
+
export function labelFromFile (rel) {
|
|
96
|
+
const base = String(rel || '').split('/').filter(Boolean).pop() || ''
|
|
97
|
+
return cleanLabel(base.replace(/\.[A-Za-z0-9]+$/, ''))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// "Daniel's AI": all of a person's AI sessions as one name (pure, shared with the app).
|
|
101
|
+
export { AI_SUFFIX, aiName, foldPersonas } from './ui/chat.js'
|
package/src/presence.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// Presence reports: the relay tells the accounts API who is in which session, and
|
|
2
|
+
// when, so people's dashboards can show their sessions and who they worked with.
|
|
3
|
+
// Only for connections with a pass (an account), and only when the relay has both
|
|
4
|
+
// QUILT_API_URL and RELAY_API_SECRET. Events wait in a queue that is kept on disk
|
|
5
|
+
// (`<dataDir>/presence-queue.jsonl`), so a restart or a down API loses nothing, and
|
|
6
|
+
// are sent every minute, at most 500 per request, in order.
|
|
7
|
+
//
|
|
8
|
+
// For the audit trail, a visit says how the member came in (`via`: the app or a hosted
|
|
9
|
+
// agent, and its `tool`), why it ended (`reason`, see END_REASONS), and what it did
|
|
10
|
+
// meanwhile (`act` events: an action and its target, a path or a task id; never file
|
|
11
|
+
// contents or chat text). The same action on the same target is reported at most once
|
|
12
|
+
// a minute per visit.
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import crypto from 'node:crypto'
|
|
15
|
+
|
|
16
|
+
export const PRESENCE_FILE = 'presence-queue.jsonl'
|
|
17
|
+
export const PRESENCE_FLUSH_MS = 60 * 1000
|
|
18
|
+
export const PRESENCE_BATCH = 500
|
|
19
|
+
export const PRESENCE_MAX_QUEUE = 100_000
|
|
20
|
+
export const PRESENCE_MAX_BACKOFF_MS = 10 * 60 * 1000
|
|
21
|
+
// A full rewrite of the queue file is O(queue size): worth doing right after load() or
|
|
22
|
+
// close(), but not on every drop or send while the relay is under load.
|
|
23
|
+
export const PRESENCE_REWRITE_MS = 60 * 1000
|
|
24
|
+
const SYNC_MS = 1000
|
|
25
|
+
export const ACT_COALESCE_MS = 60 * 1000
|
|
26
|
+
/** Why a visit ended. The accounts API turns an agent's `pass_expired` or `idle` into `revoked` when its keys were revoked first. */
|
|
27
|
+
export const END_REASONS = ['left', 'disconnected', 'removed', 'pass_expired', 'session_ended', 'relay_restart', 'idle', 'replaced', 'needs_update']
|
|
28
|
+
export const ACTIONS = ['created', 'edited', 'deleted', 'claimed', 'released', 'requested', 'handed_off', 'withdrew', 'messaged', 'task', 'tool']
|
|
29
|
+
const MAX_TARGET = 300
|
|
30
|
+
const SEND_TIMEOUT_MS = 15 * 1000
|
|
31
|
+
const CLOSE_TIMEOUT_MS = 5 * 1000
|
|
32
|
+
|
|
33
|
+
export class PresenceReporter {
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} o
|
|
36
|
+
* @param {string} o.apiUrl the accounts API, e.g. https://api.heyquilt.com
|
|
37
|
+
* @param {string} o.secret RELAY_API_SECRET. Never logged.
|
|
38
|
+
* @param {string|null} [o.file] the queue file; null keeps the queue in memory only
|
|
39
|
+
*/
|
|
40
|
+
constructor ({ apiUrl, secret, file = null, log = () => {}, now = Date.now, fetch = globalThis.fetch, flushMs = PRESENCE_FLUSH_MS, batch = PRESENCE_BATCH, maxQueue = PRESENCE_MAX_QUEUE, maxBackoffMs = PRESENCE_MAX_BACKOFF_MS, syncMs = SYNC_MS, timeoutMs = SEND_TIMEOUT_MS, rewriteMs = PRESENCE_REWRITE_MS, closeTimeoutMs = CLOSE_TIMEOUT_MS }) {
|
|
41
|
+
this.url = `${String(apiUrl).replace(/\/+$/, '')}/v1/relay/presence`
|
|
42
|
+
this.secret = secret
|
|
43
|
+
this.file = file
|
|
44
|
+
this.log = log
|
|
45
|
+
this.now = now
|
|
46
|
+
this.fetch = fetch
|
|
47
|
+
this.flushMs = flushMs
|
|
48
|
+
this.batch = batch
|
|
49
|
+
this.maxQueue = maxQueue
|
|
50
|
+
this.maxBackoffMs = maxBackoffMs
|
|
51
|
+
this.syncMs = syncMs
|
|
52
|
+
this.timeoutMs = timeoutMs
|
|
53
|
+
this.rewriteMs = rewriteMs
|
|
54
|
+
this.closeTimeoutMs = closeTimeoutMs
|
|
55
|
+
this.queue = [] // { seq, ev, startSeq? }, oldest first; an `end` notes its `start`'s seq (0: sent by an earlier run)
|
|
56
|
+
this.seq = 0
|
|
57
|
+
this.sentSeq = 0 // the highest seq the accounts API has taken
|
|
58
|
+
this.inFlight = 0 // the highest seq in the request being sent; 0 when nothing is
|
|
59
|
+
this.open = new Map() // start event id -> { start, room, account }: visits not ended yet
|
|
60
|
+
this.startSeqs = new Map() // start event id -> its seq, for visits not ended yet
|
|
61
|
+
this.unwritten = [] // queue lines not yet appended to the file
|
|
62
|
+
this.rewrite = false // the file no longer matches the queue: write it whole next time
|
|
63
|
+
this.lastRewriteAt = -Infinity // so the first rewrite (from load(), or the first drop) is never throttled
|
|
64
|
+
this.urgent = false // a rewrite a successful send is waiting on: never throttled
|
|
65
|
+
this.full = false // logged once per overflow episode; cleared once the queue drains below the cap
|
|
66
|
+
this.saveFailing = false // logged once per failure episode; cleared by the next save that works
|
|
67
|
+
this.failures = 0
|
|
68
|
+
this.retryAt = 0
|
|
69
|
+
this.sending = null
|
|
70
|
+
this.closed = false
|
|
71
|
+
this.timers = []
|
|
72
|
+
this.acted = new Map() // start event id -> Map(action|target -> when last reported), for visits not ended yet
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reads the queue file a previous run left, and ends at "now" every visit it had
|
|
77
|
+
* started and not ended (the relay crashed, or was redeployed). Returns how many.
|
|
78
|
+
*/
|
|
79
|
+
load () {
|
|
80
|
+
if (!this.file) return 0
|
|
81
|
+
let text = ''
|
|
82
|
+
try { text = fs.readFileSync(this.file, 'utf8') } catch (err) { if (err.code !== 'ENOENT') this.log(`presence: could not read ${this.file}: ${err.message}`); return 0 }
|
|
83
|
+
let bad = 0
|
|
84
|
+
const seqs = new Map() // start event id -> seq, for the starts in the file
|
|
85
|
+
const lastName = new Map() // room -> seq of its newest name in the file
|
|
86
|
+
for (const line of text.split('\n')) {
|
|
87
|
+
if (!line.trim()) continue
|
|
88
|
+
let row
|
|
89
|
+
try { row = JSON.parse(line) } catch { bad++; continue } // a line cut off by a crash
|
|
90
|
+
// JSON.parse accepts bare null/numbers/strings too: never trust the shape before checking it.
|
|
91
|
+
if (!row || typeof row !== 'object') { bad++; continue }
|
|
92
|
+
if (Array.isArray(row.open)) {
|
|
93
|
+
for (const v of row.open) if (v && v.start) this.open.set(v.start, v)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (!row.id || !row.type) { bad++; continue }
|
|
97
|
+
const seq = ++this.seq
|
|
98
|
+
if (row.type === 'start') {
|
|
99
|
+
this.open.set(row.id, { start: row.id, room: row.room, account: row.account })
|
|
100
|
+
seqs.set(row.id, seq)
|
|
101
|
+
}
|
|
102
|
+
if (row.type === 'end') this.open.delete(row.start)
|
|
103
|
+
if (row.type === 'name') lastName.set(row.room, seq)
|
|
104
|
+
// A start this file doesn't hold was sent by an earlier run: 0 marks that.
|
|
105
|
+
this.queue.push(row.type === 'end' ? { seq, ev: row, startSeq: seqs.get(row.start) || 0 } : { seq, ev: row })
|
|
106
|
+
}
|
|
107
|
+
// A room's older names (from saves between the queue's rewrites) would only be overwritten.
|
|
108
|
+
this.queue = this.queue.filter((x) => x.ev.type !== 'name' || lastName.get(x.ev.room) === x.seq)
|
|
109
|
+
for (const id of this.open.keys()) if (seqs.has(id)) this.startSeqs.set(id, seqs.get(id))
|
|
110
|
+
if (bad) this.log(`presence: skipped ${bad} unreadable line(s) in ${this.file}`)
|
|
111
|
+
const ended = this.open.size
|
|
112
|
+
this.endAll('relay_restart')
|
|
113
|
+
this.rewrite = true
|
|
114
|
+
this.persist(true) // startup: write the cleaned-up state now, not whenever the throttle allows
|
|
115
|
+
return ended
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Sends every minute (sooner retries wait for their backoff) and saves the queue every second. */
|
|
119
|
+
start () {
|
|
120
|
+
const tick = setInterval(() => { this.tick().catch(() => {}) }, this.flushMs)
|
|
121
|
+
const sync = setInterval(() => this.persist(), this.syncMs)
|
|
122
|
+
tick.unref()
|
|
123
|
+
sync.unref()
|
|
124
|
+
this.timers.push(tick, sync)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get size () { return this.queue.length }
|
|
128
|
+
|
|
129
|
+
/** Someone with a pass was let into a room. Returns the visit, for visitEnd. */
|
|
130
|
+
visitStart ({ room, account, name, owner = false, via = '', tool = '' }) {
|
|
131
|
+
if (this.closed) return null
|
|
132
|
+
const ev = { id: crypto.randomUUID(), type: 'start', room, account, name, ...(owner ? { owner: true } : {}), ...(via ? { via } : {}), ...(tool ? { tool: String(tool).slice(0, 40) } : {}), at: this.now() }
|
|
133
|
+
const visit = { start: ev.id, room, account }
|
|
134
|
+
this.open.set(ev.id, visit)
|
|
135
|
+
this.startSeqs.set(ev.id, this.enqueue(ev))
|
|
136
|
+
return visit
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** That connection left (closed, removed, ended, or its pass lapsed), for `reason`; `at` defaults to now. Once per visit. */
|
|
140
|
+
visitEnd (visit, reason = 'left', at) {
|
|
141
|
+
if (this.closed || !visit || !this.open.delete(visit.start)) return
|
|
142
|
+
this.enqueueEnd(visit, reason, at)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Someone in a visit did something: `action` (one of ACTIONS) on `target` (a path, a
|
|
147
|
+
* task id or a tool name). Repeats of the same action on the same target within a
|
|
148
|
+
* minute are not reported again.
|
|
149
|
+
*/
|
|
150
|
+
act (visit, action, target = '') {
|
|
151
|
+
if (this.closed || !visit || !this.open.has(visit.start) || !ACTIONS.includes(action)) return
|
|
152
|
+
const t = String(target || '').slice(0, MAX_TARGET)
|
|
153
|
+
const at = this.now()
|
|
154
|
+
let seen = this.acted.get(visit.start)
|
|
155
|
+
if (!seen) this.acted.set(visit.start, (seen = new Map()))
|
|
156
|
+
const k = `${action}|${t}`
|
|
157
|
+
if (at - (seen.get(k) ?? -Infinity) < ACT_COALESCE_MS) return
|
|
158
|
+
seen.set(k, at)
|
|
159
|
+
if (seen.size > 1000) for (const [key, when] of seen) if (at - when >= ACT_COALESCE_MS) seen.delete(key)
|
|
160
|
+
this.enqueue({ id: crypto.randomUUID(), type: 'act', start: visit.start, room: visit.room, account: visit.account, action, ...(t ? { target: t } : {}), at })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The owner named the session. Only the newest name matters, so a room keeps at most
|
|
165
|
+
* one waiting: a rename replaces the one queued before it (unless that one is already
|
|
166
|
+
* on its way to the API), and renames can never pile up in the queue.
|
|
167
|
+
*/
|
|
168
|
+
rename ({ room, name }) {
|
|
169
|
+
if (this.closed) return
|
|
170
|
+
const i = this.queue.findIndex((x) => x.ev.type === 'name' && x.ev.room === room && x.seq > this.inFlight)
|
|
171
|
+
if (i >= 0) {
|
|
172
|
+
this.queue.splice(i, 1)
|
|
173
|
+
this.rewrite = true // the replaced line is still in the file
|
|
174
|
+
}
|
|
175
|
+
this.enqueue({ id: crypto.randomUUID(), type: 'name', room, name, at: this.now() })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Ends every open visit now: on shutdown, and for visits a previous run left open. */
|
|
179
|
+
endAll (reason = 'relay_restart') {
|
|
180
|
+
for (const visit of [...this.open.values()]) {
|
|
181
|
+
this.open.delete(visit.start)
|
|
182
|
+
this.enqueueEnd(visit, reason)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
enqueueEnd (visit, reason, at) {
|
|
187
|
+
const startSeq = this.startSeqs.get(visit.start) || 0
|
|
188
|
+
this.startSeqs.delete(visit.start)
|
|
189
|
+
this.acted.delete(visit.start)
|
|
190
|
+
const when = Number.isFinite(at) ? Math.min(at, this.now()) : this.now()
|
|
191
|
+
this.enqueue({ id: crypto.randomUUID(), type: 'end', start: visit.start, room: visit.room, account: visit.account, ...(END_REASONS.includes(reason) ? { reason } : {}), at: when }, startSeq)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Returns the event's seq. */
|
|
195
|
+
enqueue (ev, startSeq) {
|
|
196
|
+
const seq = ++this.seq
|
|
197
|
+
this.queue.push(startSeq === undefined ? { seq, ev } : { seq, ev, startSeq })
|
|
198
|
+
this.unwritten.push(ev)
|
|
199
|
+
if (this.queue.length > this.maxQueue) this.dropOldest()
|
|
200
|
+
return seq
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Drops the oldest tenth of the cap in one go, not one event at a time: at one per
|
|
205
|
+
* event, a relay that's constantly over the cap would rewrite the whole queue file
|
|
206
|
+
* and log a line on every single enqueue. Prefers dropping the oldest `start`
|
|
207
|
+
* together with its matching `end` (if that `end` is already queued too): the pair
|
|
208
|
+
* is a visit that's over and done with, the least useful thing to still be holding.
|
|
209
|
+
* A `name` is never dropped: renames are rare, and since the `name` op is now
|
|
210
|
+
* idempotent (a repeat is never re-queued), a dropped one is gone for good. If every
|
|
211
|
+
* `start` has already been paired off or dropped and more still needs to go, the
|
|
212
|
+
* oldest unmatched `end`s go next (an `end` whose `start` was dropped is harmless: the
|
|
213
|
+
* accounts API ignores it). Never dropped: anything in the request being sent (it
|
|
214
|
+
* reaches the API anyway), and an `end` whose `start` was sent or is being sent (the
|
|
215
|
+
* visit would stay open on the dashboard forever). One `filter` pass over the queue,
|
|
216
|
+
* never a per-element shift: a drop must stay cheap even at ~100,000 events.
|
|
217
|
+
*/
|
|
218
|
+
dropOldest () {
|
|
219
|
+
const sent = Math.max(this.sentSeq, this.inFlight)
|
|
220
|
+
const drop = Math.max(this.queue.length - this.maxQueue, Math.ceil(this.maxQueue / 10))
|
|
221
|
+
const endIndexByStart = new Map() // start event id -> index of its `end`, if queued
|
|
222
|
+
this.queue.forEach((x, i) => { if (x.ev.type === 'end') endIndexByStart.set(x.ev.start, i) })
|
|
223
|
+
const remove = new Set()
|
|
224
|
+
let dropped = 0
|
|
225
|
+
// What visits did is the most plentiful and the least load-bearing: the oldest of it goes first.
|
|
226
|
+
for (let i = 0; i < this.queue.length && dropped < drop; i++) {
|
|
227
|
+
const x = this.queue[i]
|
|
228
|
+
if (x.ev.type === 'act' && x.seq > this.inFlight) { remove.add(i); dropped++ }
|
|
229
|
+
}
|
|
230
|
+
for (let i = 0; i < this.queue.length && dropped < drop; i++) {
|
|
231
|
+
const x = this.queue[i]
|
|
232
|
+
if (x.ev.type !== 'start' || x.seq <= this.inFlight || remove.has(i)) continue
|
|
233
|
+
remove.add(i); dropped++
|
|
234
|
+
const endIndex = endIndexByStart.get(x.ev.id)
|
|
235
|
+
if (endIndex !== undefined && !remove.has(endIndex)) { remove.add(endIndex); dropped++ }
|
|
236
|
+
}
|
|
237
|
+
// Nothing left to pair off a `start` with: the oldest leftover `end`s are next.
|
|
238
|
+
for (let i = 0; i < this.queue.length && dropped < drop; i++) {
|
|
239
|
+
const x = this.queue[i]
|
|
240
|
+
if (x.ev.type === 'end' && x.seq > this.inFlight && x.startSeq > sent && !remove.has(i)) { remove.add(i); dropped++ }
|
|
241
|
+
}
|
|
242
|
+
if (remove.size) this.queue = this.queue.filter((_, i) => !remove.has(i))
|
|
243
|
+
this.rewrite = true
|
|
244
|
+
if (!this.full) {
|
|
245
|
+
this.full = true
|
|
246
|
+
this.log(`presence: the queue is full (${this.maxQueue} events); dropped ${dropped} oldest event(s)`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Once the queue has drained back under the cap, the next overflow logs again. */
|
|
251
|
+
checkDrained () {
|
|
252
|
+
if (this.full && this.queue.length < this.maxQueue) this.full = false
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Saves what's new in the queue: appended and fsynced, or the whole file when it has
|
|
257
|
+
* to be (dropped or sent events leave the file out of sync with the queue, since
|
|
258
|
+
* appending can only add lines, never remove them). A whole-file rewrite is O(queue
|
|
259
|
+
* size): a drop's rewrite can wait up to `rewriteMs` (events keep being appended
|
|
260
|
+
* meanwhile, so nothing is lost), but one a successful send asked for (`urgent`) never
|
|
261
|
+
* waits, or a restart could re-read and re-send events the accounts API already has.
|
|
262
|
+
*/
|
|
263
|
+
persist (force = false) {
|
|
264
|
+
if (!this.file) return
|
|
265
|
+
const rewriteNow = this.rewrite && (force || this.urgent || this.now() - this.lastRewriteAt >= this.rewriteMs)
|
|
266
|
+
if (!rewriteNow && !this.unwritten.length) return
|
|
267
|
+
try {
|
|
268
|
+
if (rewriteNow) {
|
|
269
|
+
// Written whole, then renamed: a crash mid-write leaves the old file, never half of one.
|
|
270
|
+
const tmp = `${this.file}.tmp`
|
|
271
|
+
const lines = [JSON.stringify({ open: [...this.open.values()] }), ...this.queue.map((x) => JSON.stringify(x.ev))]
|
|
272
|
+
const fd = fs.openSync(tmp, 'w')
|
|
273
|
+
try { fs.writeSync(fd, lines.join('\n') + '\n'); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
274
|
+
fs.renameSync(tmp, this.file)
|
|
275
|
+
this.rewrite = false
|
|
276
|
+
this.urgent = false
|
|
277
|
+
this.lastRewriteAt = this.now()
|
|
278
|
+
this.unwritten = [] // the rewrite above already includes every queued event
|
|
279
|
+
} else if (this.unwritten.length) {
|
|
280
|
+
const fd = fs.openSync(this.file, 'a')
|
|
281
|
+
try { fs.writeSync(fd, this.unwritten.map((ev) => JSON.stringify(ev)).join('\n') + '\n'); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
282
|
+
this.unwritten = []
|
|
283
|
+
}
|
|
284
|
+
this.saveFailing = false
|
|
285
|
+
} catch (err) {
|
|
286
|
+
// An append that failed may have left part of a line: the next save that works
|
|
287
|
+
// writes the whole file, which has every queued event, so nothing waits to be appended.
|
|
288
|
+
this.rewrite = true
|
|
289
|
+
this.unwritten = []
|
|
290
|
+
if (!this.saveFailing) this.log(`presence: could not save the queue: ${err.message}`)
|
|
291
|
+
this.saveFailing = true
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Sends now unless a failure's backoff hasn't run out. */
|
|
296
|
+
async tick () {
|
|
297
|
+
if (this.now() < this.retryAt) return false
|
|
298
|
+
return this.flush()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Sends everything queued, oldest first, in requests of at most 500. Resolves true
|
|
303
|
+
* when the queue is empty, false after a failure (the events stay for the retry).
|
|
304
|
+
*/
|
|
305
|
+
flush () {
|
|
306
|
+
if (!this.sending) this.sending = this.send().finally(() => { this.sending = null })
|
|
307
|
+
return this.sending
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async send () {
|
|
311
|
+
let sent = false
|
|
312
|
+
try {
|
|
313
|
+
while (this.queue.length) {
|
|
314
|
+
const chunk = this.queue.slice(0, this.batch)
|
|
315
|
+
this.inFlight = chunk[chunk.length - 1].seq
|
|
316
|
+
const res = await this.fetch(this.url, {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${this.secret}` },
|
|
319
|
+
body: JSON.stringify({ events: chunk.map((x) => x.ev) }),
|
|
320
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
321
|
+
})
|
|
322
|
+
if (!res.ok) throw new Error(`the accounts API answered ${res.status}`)
|
|
323
|
+
// By sequence number: the queue may have dropped its oldest while this was in flight.
|
|
324
|
+
const last = this.inFlight
|
|
325
|
+
this.sentSeq = last
|
|
326
|
+
this.inFlight = 0
|
|
327
|
+
const i = this.queue.findIndex((x) => x.seq > last)
|
|
328
|
+
this.queue = i < 0 ? [] : this.queue.slice(i)
|
|
329
|
+
this.checkDrained()
|
|
330
|
+
sent = true
|
|
331
|
+
}
|
|
332
|
+
this.failures = 0
|
|
333
|
+
this.retryAt = 0
|
|
334
|
+
return true
|
|
335
|
+
} catch (err) {
|
|
336
|
+
this.failures++
|
|
337
|
+
const wait = Math.min(this.flushMs * 2 ** (this.failures - 1), this.maxBackoffMs)
|
|
338
|
+
this.retryAt = this.now() + wait
|
|
339
|
+
this.log(`presence: could not report to the accounts API (${err.cause?.code || err.message}); trying again in ${Math.round(wait / 1000)} s`)
|
|
340
|
+
return false
|
|
341
|
+
} finally {
|
|
342
|
+
this.inFlight = 0
|
|
343
|
+
if (sent) { this.rewrite = true; this.urgent = true; this.persist() }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** On shutdown: ends every open visit, stops the timers, saves the queue, and tries one last send (bounded by closeTimeoutMs). */
|
|
348
|
+
async close () {
|
|
349
|
+
if (this.closed) return
|
|
350
|
+
for (const t of this.timers) clearInterval(t)
|
|
351
|
+
this.timers = []
|
|
352
|
+
this.endAll('relay_restart')
|
|
353
|
+
this.persist(true)
|
|
354
|
+
this.closed = true
|
|
355
|
+
let timer
|
|
356
|
+
await Promise.race([this.flush(), new Promise((resolve) => { timer = setTimeout(resolve, this.closeTimeoutMs) })])
|
|
357
|
+
clearTimeout(timer)
|
|
358
|
+
this.persist(true)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Files only you should read (this computer's sign-in, agents' keys): written
|
|
2
|
+
// atomically with mode 0600, and never through a symlink.
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import crypto from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
/** True if `file` is a symlink (a missing file isn't). */
|
|
8
|
+
export function isSymlink (file) {
|
|
9
|
+
try {
|
|
10
|
+
return fs.lstatSync(file).isSymbolicLink()
|
|
11
|
+
} catch (err) {
|
|
12
|
+
if (err.code === 'ENOENT') return false
|
|
13
|
+
throw err
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Writes JSON to `file`: a private temp file in the same folder, fsynced, then
|
|
19
|
+
* renamed over the target. The rename replaces whatever is there without
|
|
20
|
+
* following it, and a half-written file is never seen at the real path.
|
|
21
|
+
*/
|
|
22
|
+
export function writePrivateJson (file, data) {
|
|
23
|
+
if (isSymlink(file)) throw new Error(`Refusing to write ${file}: it's a symlink`)
|
|
24
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`)
|
|
25
|
+
const fd = fs.openSync(tmp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600)
|
|
26
|
+
try {
|
|
27
|
+
fs.writeSync(fd, JSON.stringify(data, null, 2))
|
|
28
|
+
fs.fsyncSync(fd)
|
|
29
|
+
} catch (err) {
|
|
30
|
+
fs.closeSync(fd)
|
|
31
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
32
|
+
throw err
|
|
33
|
+
}
|
|
34
|
+
fs.closeSync(fd)
|
|
35
|
+
try {
|
|
36
|
+
fs.renameSync(tmp, file)
|
|
37
|
+
} catch (err) {
|
|
38
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
39
|
+
throw err
|
|
40
|
+
}
|
|
41
|
+
}
|