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,178 @@
|
|
|
1
|
+
// The project's own rules for AI, gathered from whichever tool's files hold them, so every
|
|
2
|
+
// agent reads the same thing whatever tool it runs in (quilt_project_rules, on the local and
|
|
3
|
+
// hosted MCP): AGENTS.md at the root and in folders (or CLAUDE.md when there is none), the
|
|
4
|
+
// Cursor rules that always apply, and the design system.
|
|
5
|
+
//
|
|
6
|
+
// Pure: `list` is the session's file paths, `read(path)` their text (or null).
|
|
7
|
+
|
|
8
|
+
const START = '<!-- quilt:start -->'
|
|
9
|
+
const END = '<!-- quilt:end -->'
|
|
10
|
+
const MAX_FILE = 20000
|
|
11
|
+
const MAX_TOTAL = 60000
|
|
12
|
+
const SKIP = /(^|\/)(node_modules|\.git|\.quilt|dist|build|vendor)\//
|
|
13
|
+
// The design system: named in AGENTS.md ("Design system: docs/brand.md"), else a usual name.
|
|
14
|
+
const DESIGN_NAMES = ['DESIGN.md', 'design.md', 'DESIGN_SYSTEM.md', 'design-system.md', 'docs/DESIGN.md', 'docs/design.md', 'docs/design-system.md']
|
|
15
|
+
const DESIGN_LINE = /^\s*(?:[-*]\s*)?\**design system\**\s*:\s*`?([^\s`]+\.(?:md|mdx|txt))`?/im
|
|
16
|
+
|
|
17
|
+
/** A rules file without Quilt's own guide block (agents have that from Quilt already). */
|
|
18
|
+
export function withoutQuiltBlock (text) {
|
|
19
|
+
const s = String(text || '')
|
|
20
|
+
const a = s.indexOf(START)
|
|
21
|
+
const b = s.indexOf(END)
|
|
22
|
+
if (a === -1 || b === -1 || b < a) return s.trim()
|
|
23
|
+
return (s.slice(0, a) + s.slice(b + END.length)).replace(/\n{3,}/g, '\n\n').trim()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A Cursor rule's front matter: { alwaysApply, globs, description } and its body. */
|
|
27
|
+
export function parseCursorRule (text) {
|
|
28
|
+
const s = String(text || '')
|
|
29
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(s)
|
|
30
|
+
const meta = { alwaysApply: false, globs: [], description: '' }
|
|
31
|
+
if (!m) return { meta, body: s.trim() }
|
|
32
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
33
|
+
const kv = /^(\w+)\s*:\s*(.*)$/.exec(line.trim())
|
|
34
|
+
if (!kv) continue
|
|
35
|
+
const [, k, v] = kv
|
|
36
|
+
if (k === 'alwaysApply') meta.alwaysApply = /^true$/i.test(v.trim())
|
|
37
|
+
else if (k === 'description') meta.description = v.trim().replace(/^["']|["']$/g, '')
|
|
38
|
+
else if (k === 'globs') meta.globs = v.replace(/^\[|\]$/g, '').split(',').map((g) => g.trim().replace(/^["']|["']$/g, '')).filter(Boolean)
|
|
39
|
+
}
|
|
40
|
+
return { meta, body: s.slice(m[0].length).trim() }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether a CLAUDE.md (or GEMINI.md) only points at AGENTS.md, with nothing of its own. */
|
|
44
|
+
export const onlyPointer = (text) => withoutQuiltBlock(text).replace(/^\s*@AGENTS\.md\s*$/m, '').trim() === ''
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The rules: { files: [{ path, kind, text }], design: { path, text } | null, missing: [] }.
|
|
48
|
+
* `kind`: 'agents' (AGENTS.md), 'claude' (CLAUDE.md, when there is no AGENTS.md), 'cursor'.
|
|
49
|
+
*/
|
|
50
|
+
export function projectRules ({ list = [], read = () => null } = {}) {
|
|
51
|
+
const paths = [...new Set(list)].filter((p) => typeof p === 'string' && !SKIP.test(p)).sort((a, b) => depth(a) - depth(b) || a.localeCompare(b))
|
|
52
|
+
const files = []
|
|
53
|
+
let total = 0
|
|
54
|
+
const add = (path, kind, text) => {
|
|
55
|
+
const t = String(text || '').trim()
|
|
56
|
+
if (!t || total >= MAX_TOTAL) return
|
|
57
|
+
const cut = t.length > MAX_FILE ? `${t.slice(0, MAX_FILE)}\n\n… (cut: read ${path} for the rest)` : t
|
|
58
|
+
total += cut.length
|
|
59
|
+
files.push({ path, kind, text: cut })
|
|
60
|
+
}
|
|
61
|
+
const agents = paths.filter((p) => p === 'AGENTS.md' || p.endsWith('/AGENTS.md'))
|
|
62
|
+
for (const p of agents) add(p, 'agents', withoutQuiltBlock(read(p)))
|
|
63
|
+
// No AGENTS.md: a CLAUDE.md holds the rules (Claude Code's own file), at the root and in folders.
|
|
64
|
+
if (!files.length) for (const p of paths.filter((x) => x === 'CLAUDE.md' || x.endsWith('/CLAUDE.md'))) add(p, 'claude', withoutQuiltBlock(read(p)))
|
|
65
|
+
// Cursor's rules that apply everywhere; ones for some files only are named, to read when working on those.
|
|
66
|
+
const scoped = []
|
|
67
|
+
for (const p of paths.filter((x) => /^\.cursor\/rules\/.+\.mdc?$/.test(x))) {
|
|
68
|
+
const { meta, body } = parseCursorRule(read(p))
|
|
69
|
+
if (meta.alwaysApply) add(p, 'cursor', body)
|
|
70
|
+
else if (body) scoped.push({ path: p, globs: meta.globs, description: meta.description })
|
|
71
|
+
}
|
|
72
|
+
if (paths.includes('.cursorrules')) add('.cursorrules', 'cursor', read('.cursorrules'))
|
|
73
|
+
// The design system: named in the rules, else a usual file name.
|
|
74
|
+
let design = null
|
|
75
|
+
const named = files.map((f) => DESIGN_LINE.exec(f.text)?.[1]).find(Boolean)
|
|
76
|
+
const designPath = [named, ...DESIGN_NAMES].find((p) => p && paths.includes(p.replace(/^\.\//, '')))
|
|
77
|
+
if (designPath) {
|
|
78
|
+
const t = String(read(designPath.replace(/^\.\//, '')) || '').trim()
|
|
79
|
+
if (t) design = { path: designPath.replace(/^\.\//, ''), text: t.length > MAX_FILE ? `${t.slice(0, MAX_FILE)}\n\n… (cut: read ${designPath} for the rest)` : t }
|
|
80
|
+
}
|
|
81
|
+
return { files, scoped, design }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const depth = (p) => p.split('/').length
|
|
85
|
+
|
|
86
|
+
/** The rules as one text for an agent. */
|
|
87
|
+
export function renderRules (r) {
|
|
88
|
+
if (!r.files.length && !r.design && !r.scoped.length) {
|
|
89
|
+
return 'This project has no rules for AI yet (no AGENTS.md, CLAUDE.md or Cursor rules) and no design system file. Follow the code around what you change.'
|
|
90
|
+
}
|
|
91
|
+
const out = ['The project\'s rules for AI, the same for every tool. Folder AGENTS.md files apply to the files under that folder.', '']
|
|
92
|
+
for (const f of r.files) out.push(`## ${f.path}`, '', f.text, '')
|
|
93
|
+
if (r.scoped.length) {
|
|
94
|
+
out.push('## Rules for some files only (Cursor)', '')
|
|
95
|
+
for (const s of r.scoped) out.push(`- ${s.path}${s.globs.length ? ` (for ${s.globs.join(', ')})` : ''}${s.description ? `: ${s.description}` : ''}`)
|
|
96
|
+
out.push('', 'Read one with your file tools when you work on files it covers.', '')
|
|
97
|
+
}
|
|
98
|
+
if (r.design) out.push(`## Design system (${r.design.path})`, '', r.design.text, '')
|
|
99
|
+
return out.join('\n').trim()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ------------------------------------------------- gathering into AGENTS.md --
|
|
103
|
+
|
|
104
|
+
/** The one folder every glob of a Cursor rule is under ("src/api/**\/*.ts" → "src/api"), or null. */
|
|
105
|
+
export function globFolder (globs) {
|
|
106
|
+
const dirs = (globs || []).map((g) => {
|
|
107
|
+
const parts = String(g).replace(/^\.\//, '').split('/')
|
|
108
|
+
const fixed = []
|
|
109
|
+
for (const p of parts.slice(0, -1)) { if (/[*?[{]/.test(p)) break; fixed.push(p) }
|
|
110
|
+
return fixed.join('/')
|
|
111
|
+
})
|
|
112
|
+
if (!dirs.length || dirs.some((d) => !d)) return null
|
|
113
|
+
return dirs.every((d) => d === dirs[0]) ? dirs[0] : null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const section = (title, body) => `## ${title}\n\n${String(body).trim()}\n`
|
|
117
|
+
const joinTo = (before, parts) => [String(before || '').replace(/\s*$/, ''), ...parts.map((p) => p.trim())].filter(Boolean).join('\n\n').replace(/^\n+/, '') + '\n'
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What gathering the project's rules into AGENTS.md would change, for the person to look at
|
|
121
|
+
* before it is applied: { changes: [{ path, before, after }] (after null: the file goes),
|
|
122
|
+
* manual: [{ path, why }] }. CLAUDE.md and GEMINI.md keep only a pointer at AGENTS.md (and
|
|
123
|
+
* Quilt's guide); Cursor rules that always apply, and .cursorrules, move into AGENTS.md; a
|
|
124
|
+
* rule for files in one folder moves into that folder's AGENTS.md; anything else is listed for
|
|
125
|
+
* the person to place by hand.
|
|
126
|
+
*/
|
|
127
|
+
export function gatherPlan ({ list = [], read = () => null } = {}) {
|
|
128
|
+
const paths = [...new Set(list)].filter((p) => typeof p === 'string' && !SKIP.test(p)).sort()
|
|
129
|
+
const has = new Set(paths)
|
|
130
|
+
const writes = new Map() // path -> { before, parts: [] }
|
|
131
|
+
const removes = new Map() // path -> before
|
|
132
|
+
const pointers = new Map() // path -> { before, after }
|
|
133
|
+
const manual = []
|
|
134
|
+
const into = (folder, title, body) => {
|
|
135
|
+
const p = folder ? `${folder}/AGENTS.md` : 'AGENTS.md'
|
|
136
|
+
if (!writes.has(p)) writes.set(p, { before: has.has(p) ? String(read(p) ?? '') : null, parts: [] })
|
|
137
|
+
writes.get(p).parts.push(section(title, body))
|
|
138
|
+
return p
|
|
139
|
+
}
|
|
140
|
+
// A tool's own file (CLAUDE.md, GEMINI.md) at the root or in a folder: its rules go to that folder's AGENTS.md.
|
|
141
|
+
for (const p of paths.filter((x) => /(^|\/)(CLAUDE|GEMINI)\.md$/.test(x))) {
|
|
142
|
+
const before = String(read(p) ?? '')
|
|
143
|
+
const own = withoutQuiltBlock(before).replace(/^\s*@AGENTS\.md\s*$/m, '').trim()
|
|
144
|
+
if (!own) continue
|
|
145
|
+
const folder = p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : ''
|
|
146
|
+
into(folder, `From ${p.split('/').pop()}`, own)
|
|
147
|
+
const block = before.match(/<!-- quilt:start -->[\s\S]*?<!-- quilt:end -->/)
|
|
148
|
+
pointers.set(p, { before, after: `@AGENTS.md\n${block ? `\n${block[0]}\n` : ''}` })
|
|
149
|
+
}
|
|
150
|
+
for (const p of paths.filter((x) => /^\.cursor\/rules\/.+\.mdc?$/.test(x))) {
|
|
151
|
+
const before = String(read(p) ?? '')
|
|
152
|
+
const { meta, body } = parseCursorRule(before)
|
|
153
|
+
if (!body) continue
|
|
154
|
+
const title = meta.description || p.split('/').pop().replace(/\.mdc?$/, '')
|
|
155
|
+
if (meta.alwaysApply) { removes.set(p, { before, to: into('', title, body) }); continue }
|
|
156
|
+
const folder = globFolder(meta.globs)
|
|
157
|
+
if (folder) { removes.set(p, { before, to: into(folder, title, body) }); continue }
|
|
158
|
+
manual.push({ path: p, why: meta.globs.length ? `it applies to ${meta.globs.join(', ')}, which isn't one folder` : 'Cursor applies it when asked for, not to a folder' })
|
|
159
|
+
}
|
|
160
|
+
if (has.has('.cursorrules')) {
|
|
161
|
+
const before = String(read('.cursorrules') ?? '')
|
|
162
|
+
if (before.trim()) removes.set('.cursorrules', { before, to: into('', 'From .cursorrules', before) })
|
|
163
|
+
}
|
|
164
|
+
const changes = []
|
|
165
|
+
for (const [p, w] of writes) changes.push({ path: p, before: w.before, after: joinTo(w.before, w.parts) })
|
|
166
|
+
for (const [p, c] of pointers) if (c.after !== c.before) changes.push({ path: p, before: c.before, after: c.after })
|
|
167
|
+
for (const [p, r] of removes) changes.push({ path: p, before: r.before, after: null, to: r.to })
|
|
168
|
+
changes.sort((a, b) => (a.path === 'AGENTS.md' ? -1 : b.path === 'AGENTS.md' ? 1 : a.path.localeCompare(b.path)))
|
|
169
|
+
return { changes, manual }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A short fingerprint of a plan, so what is applied is what was shown. */
|
|
173
|
+
export function planKey (plan) {
|
|
174
|
+
let h = 0x811c9dc5
|
|
175
|
+
const s = JSON.stringify(plan.changes.map((c) => [c.path, c.before, c.after]))
|
|
176
|
+
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
|
|
177
|
+
return `${s.length}:${(h >>> 0).toString(36)}`
|
|
178
|
+
}
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Wire protocol shared by the relay server and clients.
|
|
2
|
+
// Compatible with the standard y-websocket message layout.
|
|
3
|
+
import * as encoding from 'lib0/encoding'
|
|
4
|
+
import * as decoding from 'lib0/decoding'
|
|
5
|
+
import * as syncProtocol from 'y-protocols/sync'
|
|
6
|
+
import * as awarenessProtocol from 'y-protocols/awareness'
|
|
7
|
+
|
|
8
|
+
export const MSG_SYNC = 0
|
|
9
|
+
export const MSG_AWARENESS = 1
|
|
10
|
+
export const MSG_QUERY_AWARENESS = 3
|
|
11
|
+
// quilt extensions (ignored by plain y-websocket clients):
|
|
12
|
+
export const MSG_AUTH = 10 // relay -> client: nonce; client -> relay: signature
|
|
13
|
+
export const MSG_CLAIM = 11 // client -> relay: JSON { id, op: 'claim'|'release', pattern, note }
|
|
14
|
+
export const MSG_CLAIMS = 12 // relay -> client: JSON { claims, reply?: { id, ok, error, released } }
|
|
15
|
+
export const MSG_ACCESS = 13 // relay -> client: JSON { state: 'pending'|'approved', role, scopes, scopesExcept, talk, owner, controlled, refresh?, refused?, why? }
|
|
16
|
+
export const MSG_ADMIN = 14 // client (owner) -> relay: JSON { id, op: 'approve'|'deny'|'set'|'remove'|'end'|'name', key, role, scopes, access?, typeId?, name }
|
|
17
|
+
export const MSG_MEMBERS = 15 // relay -> client: JSON { members, sessionName, pending?, reply?: { id, ok, error } }
|
|
18
|
+
export const MSG_PASS = 16 // client -> relay: JSON { pass }: a fresh session pass, sent at least every 5 minutes
|
|
19
|
+
export const MSG_BRANCH = 17 // client -> relay: JSON { id, op: 'join'|'confirm'|'remove', branch, base?, adopt?, sv? }
|
|
20
|
+
export const MSG_BRANCHES = 18 // relay -> client: JSON { branches, reply?: { id, op, ok, error?, branch?, created?, base? } }
|
|
21
|
+
|
|
22
|
+
// Every sync message names its document: the room's (chat, tasks, the feed, commit requests,
|
|
23
|
+
// activity) is '', a branch's (its files) is the branch key. Git never allows an empty branch name.
|
|
24
|
+
export const ROOM_DOC = ''
|
|
25
|
+
|
|
26
|
+
// What this app can do, sent when it connects. The relay turns away apps without what a session needs.
|
|
27
|
+
export const FEATURES = 'large-files,branches'
|
|
28
|
+
|
|
29
|
+
// WebSocket close codes the relay uses to refuse a client for good.
|
|
30
|
+
export const CLOSE_AUTH_FAILED = 4401
|
|
31
|
+
export const CLOSE_NAME_TAKEN = 4403
|
|
32
|
+
export const CLOSE_DENIED = 4406 // the owner said no, or removed you
|
|
33
|
+
|
|
34
|
+
// WebSocket close code the relay uses when a room is over its size quota.
|
|
35
|
+
export const CLOSE_ROOM_FULL = 4413
|
|
36
|
+
|
|
37
|
+
export const CLOSE_ENDED = 4410 // the owner ended the session and it was deleted
|
|
38
|
+
|
|
39
|
+
// The connection's session pass ran out without a new one. Clients reconnect with a fresh pass.
|
|
40
|
+
export const CLOSE_PASS_EXPIRED = 4419
|
|
41
|
+
|
|
42
|
+
// Sent to apps too old for a session that now stores large files. It reuses
|
|
43
|
+
// 4401 because every app that knows close codes stops on it and shows the
|
|
44
|
+
// reason; the oldest ones reconnect instead, and the relay refuses that
|
|
45
|
+
// reconnect with the same message.
|
|
46
|
+
export const CLOSE_NEEDS_UPDATE = CLOSE_AUTH_FAILED
|
|
47
|
+
|
|
48
|
+
// Largest file that can be sent in chat.
|
|
49
|
+
export const MAX_SHARED_FILE_BYTES = 100 * 1024 * 1024
|
|
50
|
+
|
|
51
|
+
export { encoding, decoding, syncProtocol, awarenessProtocol }
|
|
52
|
+
|
|
53
|
+
/** An encoder with a sync message's header written: the type and the document it is for. */
|
|
54
|
+
export function syncHeader (docId = ROOM_DOC) {
|
|
55
|
+
const enc = encoding.createEncoder()
|
|
56
|
+
encoding.writeVarUint(enc, MSG_SYNC)
|
|
57
|
+
encoding.writeVarString(enc, docId)
|
|
58
|
+
return enc
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function syncStep1Message (doc, docId = ROOM_DOC) {
|
|
62
|
+
const enc = syncHeader(docId)
|
|
63
|
+
syncProtocol.writeSyncStep1(enc, doc)
|
|
64
|
+
return encoding.toUint8Array(enc)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function updateMessage (update, docId = ROOM_DOC) {
|
|
68
|
+
const enc = syncHeader(docId)
|
|
69
|
+
syncProtocol.writeUpdate(enc, update)
|
|
70
|
+
return encoding.toUint8Array(enc)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function awarenessMessage (awareness, clients) {
|
|
74
|
+
const enc = encoding.createEncoder()
|
|
75
|
+
encoding.writeVarUint(enc, MSG_AWARENESS)
|
|
76
|
+
encoding.writeVarUint8Array(enc, awarenessProtocol.encodeAwarenessUpdate(awareness, clients))
|
|
77
|
+
return encoding.toUint8Array(enc)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function bytesMessage (type, bytes) {
|
|
81
|
+
const enc = encoding.createEncoder()
|
|
82
|
+
encoding.writeVarUint(enc, type)
|
|
83
|
+
encoding.writeVarUint8Array(enc, bytes)
|
|
84
|
+
return encoding.toUint8Array(enc)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function jsonMessage (type, value) {
|
|
88
|
+
const enc = encoding.createEncoder()
|
|
89
|
+
encoding.writeVarUint(enc, type)
|
|
90
|
+
encoding.writeVarString(enc, JSON.stringify(value))
|
|
91
|
+
return encoding.toUint8Array(enc)
|
|
92
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Node prints "SQLite is an experimental feature" the moment node:sqlite loads, which
|
|
2
|
+
// Quilt does to read Cursor's chat database. The warning is known and harmless, so it is
|
|
3
|
+
// kept off people's terminals; every other warning is still printed as Node would.
|
|
4
|
+
const printers = process.listeners('warning')
|
|
5
|
+
process.removeAllListeners('warning')
|
|
6
|
+
process.on('warning', (warning) => {
|
|
7
|
+
if (warning && warning.name === 'ExperimentalWarning' && /sqlite/i.test(String(warning.message))) return
|
|
8
|
+
for (const print of printers) print.call(process, warning)
|
|
9
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The relay wakes a person's cloud AI (routes/ai-vm.js on the API) when something waits for it
|
|
2
|
+
// and it isn't connected (its machine suspends when idle): an @mention, a direct message or a
|
|
3
|
+
// task for it, or for "<person>'s AI" while none of the person's AI sessions on a computer is
|
|
4
|
+
// live. A room remembers each cloud AI that joined it (meta.cloudAis, keyed by its person's
|
|
5
|
+
// account) with what it has been told of, so a wake is asked for once per new event, and the
|
|
6
|
+
// machine's own inbox finds the event when it is back.
|
|
7
|
+
import { scanInbox } from './inbox.js'
|
|
8
|
+
import { readTasks } from './tasks.js'
|
|
9
|
+
import { cloudReader } from './cloud-ai.js'
|
|
10
|
+
|
|
11
|
+
const PERSON = /^person:[A-Za-z0-9_-]{1,64}$/
|
|
12
|
+
|
|
13
|
+
/** Notes the cloud AI a pass is for in `meta.cloudAis`; returns whether anything changed. */
|
|
14
|
+
export function noteCloudAi (meta, pass, name) {
|
|
15
|
+
if (!pass || pass.kind !== 'agent' || !PERSON.test(String(pass.of || '')) || typeof pass.ofName !== 'string') return false
|
|
16
|
+
meta.cloudAis ||= {}
|
|
17
|
+
const old = meta.cloudAis[pass.of]
|
|
18
|
+
const agent = `agent:${pass.sub}`
|
|
19
|
+
if (old && old.agent === agent && old.name === name && old.ofName === pass.ofName) return false
|
|
20
|
+
meta.cloudAis[pass.of] = { agent, name, ofName: pass.ofName, inbox: old && old.agent === agent ? old.inbox : null }
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a connection in `room` is `account` ('agent:<id>'). */
|
|
25
|
+
const connected = (room, account) => [...room.conns.keys()].some((ws) => ws.pass && `${ws.pass.kind}:${ws.pass.sub}` === account)
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Looks at `room` for each cloud AI it knows: what is new for it. Returns the cloud AIs to wake
|
|
29
|
+
* ([{ agent, room }]); `changed` says whether meta needs saving. Pure but for the room it reads.
|
|
30
|
+
*/
|
|
31
|
+
export function cloudAisToWake (room) {
|
|
32
|
+
const out = []
|
|
33
|
+
let changed = false
|
|
34
|
+
const list = room.meta.cloudAis || {}
|
|
35
|
+
if (!Object.keys(list).length) return { wake: out, changed }
|
|
36
|
+
const states = [...room.awareness.getStates().values()]
|
|
37
|
+
const chat = room.doc.getArray('chat').toArray()
|
|
38
|
+
const tasks = readTasks(room.doc.getMap('tasks'))
|
|
39
|
+
for (const entry of Object.values(list)) {
|
|
40
|
+
if (!entry || !entry.agent) continue
|
|
41
|
+
const reader = cloudReader({ name: entry.name, person: entry.ofName, states })
|
|
42
|
+
const messages = chat.filter((m) => m && m.id && (!m.to || m.to === entry.name || reader.aliases.includes(m.to) || m.by === entry.name))
|
|
43
|
+
const got = scanInbox({ messages, tasks, reader }, entry.inbox || null)
|
|
44
|
+
const seeded = !entry.inbox
|
|
45
|
+
entry.inbox = got.state
|
|
46
|
+
changed = true
|
|
47
|
+
if (seeded || !got.events.length) continue
|
|
48
|
+
if (!connected(room, entry.agent)) out.push({ agent: entry.agent.slice('agent:'.length), room: room.name })
|
|
49
|
+
}
|
|
50
|
+
return { wake: out, changed }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Watches rooms' chat and boards and asks the API (`apiUrl`, signed with `secret`) to wake each
|
|
55
|
+
* cloud AI something waits for. `fetch` is for tests.
|
|
56
|
+
*/
|
|
57
|
+
export function cloudAiWaker ({ apiUrl, secret, fetch: fetchImpl = globalThis.fetch, log = () => {} }) {
|
|
58
|
+
const watched = new WeakSet()
|
|
59
|
+
const base = String(apiUrl).replace(/\/+$/, '')
|
|
60
|
+
const ask = (w) => fetchImpl(`${base}/v1/relay/ai-vm/wake`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` },
|
|
63
|
+
body: JSON.stringify(w),
|
|
64
|
+
signal: AbortSignal.timeout(30_000)
|
|
65
|
+
}).then((res) => { if (!res.ok) log(`[${w.room}] could not wake a cloud AI (${res.status})`) }, (err) => log(`[${w.room}] could not wake a cloud AI: ${err.message}`))
|
|
66
|
+
|
|
67
|
+
const check = (room) => {
|
|
68
|
+
const { wake, changed } = cloudAisToWake(room)
|
|
69
|
+
if (changed) room.saveMeta()
|
|
70
|
+
return Promise.all(wake.map(ask))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const watch = (room) => {
|
|
74
|
+
if (watched.has(room)) return
|
|
75
|
+
watched.add(room)
|
|
76
|
+
const run = () => { try { check(room) } catch (err) { log(`[${room.name}] cloud AI wake: ${err.message}`) } }
|
|
77
|
+
room.doc.getArray('chat').observe(run)
|
|
78
|
+
room.doc.getMap('tasks').observeDeep(run)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { watch, check }
|
|
82
|
+
}
|
package/src/relay-ask.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Questions for the relay, "Quilt" in chat. Anyone in a session can @mention @Quilt (or message it directly)
|
|
2
|
+
// and it answers from what it knows: how the session's branches stand against git, who is
|
|
3
|
+
// connected, which branches it keeps, and (for the owner) trimming branches nobody uses. No
|
|
4
|
+
// model runs here: each question is matched by its words and answered by code. Pure but for
|
|
5
|
+
// `room`, which the relay passes in (server.js answerRelay).
|
|
6
|
+
|
|
7
|
+
import { RELAY_BY, isRelay } from './branches.js'
|
|
8
|
+
import { mentioned } from './inbox.js'
|
|
9
|
+
import { RELAY_QUESTIONS, RELAY_OLD_NAMES } from './ui/chat.js'
|
|
10
|
+
|
|
11
|
+
export { RELAY_QUESTIONS }
|
|
12
|
+
|
|
13
|
+
/** A branch nobody has been on for this long, with nobody on it now, is unused (trim). */
|
|
14
|
+
export const TRIM_IDLE_MS = 24 * 60 * 60 * 1000
|
|
15
|
+
|
|
16
|
+
/** Whether chat message `m` is for the relay: sent to it directly, or @mentioning it. */
|
|
17
|
+
export function asksRelay (m) {
|
|
18
|
+
if (!m || typeof m !== 'object' || typeof m.text !== 'string' || isRelay(m.by)) return false
|
|
19
|
+
if (m.to) return isRelay(m.to)
|
|
20
|
+
return mentioned(m.text, [RELAY_BY, ...RELAY_OLD_NAMES]).length > 0
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Which question `text` asks (an id of RELAY_QUESTIONS), or 'help' when it is none of them. */
|
|
24
|
+
export function questionOf (text) {
|
|
25
|
+
const t = String(text || '').replace(/@(the relay|quilt)\b/gi, ' ').toLowerCase()
|
|
26
|
+
if (/\b(trim|prune|clean ?up|remove)\b/.test(t)) return 'trim'
|
|
27
|
+
if (/\b(git|github|upstream|commits?|behind|synced?)\b/.test(t)) return 'git'
|
|
28
|
+
if (/\b(members?|connected|online|who|people|agents?)\b/.test(t)) return 'members'
|
|
29
|
+
if (/\bbranch(es)?\b/.test(t)) return 'branches'
|
|
30
|
+
return 'help'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : /(ch|sh|s|x)$/.test(w) ? 'es' : 's'}`
|
|
34
|
+
|
|
35
|
+
/** "3 min ago", "2 h ago", "4 days ago". */
|
|
36
|
+
export function ago (ts, now = Date.now()) {
|
|
37
|
+
const s = Math.max(0, Math.round((now - ts) / 1000))
|
|
38
|
+
if (s < 60) return 'just now'
|
|
39
|
+
if (s < 3600) return `${Math.round(s / 60)} min ago`
|
|
40
|
+
if (s < 86400) return `${Math.round(s / 3600)} h ago`
|
|
41
|
+
return plural(Math.round(s / 86400), 'day') + ' ago'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const list = (names) => names.length <= 2 ? names.join(' and ') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
|
|
45
|
+
|
|
46
|
+
/** Who is on each branch now: Map key -> [names], from connected apps and hosted agents active now. */
|
|
47
|
+
function whoIsOn (room) {
|
|
48
|
+
const on = new Map()
|
|
49
|
+
const add = (k, n) => { if (k && n && !(on.get(k) || []).includes(n)) on.set(k, [...(on.get(k) || []), n]) }
|
|
50
|
+
for (const ws of room.conns.keys()) add(ws.branch, room.names.get(ws))
|
|
51
|
+
for (const h of room.hostedOnline()) add(room.hostedBranch(h.id), h.name)
|
|
52
|
+
return on
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function gitAnswer (room, now) {
|
|
56
|
+
const ups = room.meta.upstreams || {}
|
|
57
|
+
const lines = []
|
|
58
|
+
for (const key of Object.keys(room.meta.branches)) {
|
|
59
|
+
const r = ups[key]
|
|
60
|
+
if (!r || !r.name) continue
|
|
61
|
+
const parts = [`${key} follows ${r.name}${r.repo ? ` (${r.repo})` : ''}`]
|
|
62
|
+
if (r.sha) parts.push(`at ${r.sha.slice(0, 7)}${r.checkedAt ? `, checked ${ago(r.checkedAt, now)}` : ''}`)
|
|
63
|
+
if (r.brought && r.brought.at) parts.push(`brought in ${plural(r.brought.count || 0, 'commit')} ${ago(r.brought.at, now)}`)
|
|
64
|
+
if (r.clash && r.clash.task) parts.push(`waiting on merge task ${r.clash.task}`)
|
|
65
|
+
if (r.problem) parts.push(`problem: ${typeof r.problem === 'string' ? r.problem : r.problem.message || 'see the branch menu'}`)
|
|
66
|
+
const why = r.github && GITHUB_BLOCKED[r.github.state]
|
|
67
|
+
if (why) parts.push(`agents can't commit: ${why}`)
|
|
68
|
+
lines.push(parts.join('; '))
|
|
69
|
+
}
|
|
70
|
+
if (!lines.length) return 'No branch here follows a GitHub repository yet. It starts when a member with a git folder joins.'
|
|
71
|
+
return lines.length === 1 ? `${lines[0]}.` : `Against git:\n${lines.map((l) => `- ${l}`).join('\n')}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Why agents can't commit to a branch's repository, by its GitHub connection's state (relay-upstream.js noteConnection).
|
|
75
|
+
const GITHUB_BLOCKED = {
|
|
76
|
+
'not-connected': 'nobody here has connected GitHub',
|
|
77
|
+
'not-installed': 'the Quilt app isn\'t installed on the repository',
|
|
78
|
+
'no-access': 'the connected GitHub account can\'t write to it'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function membersAnswer (room) {
|
|
82
|
+
const people = new Set()
|
|
83
|
+
for (const ws of room.conns.keys()) { const n = room.names.get(ws); if (n) people.add(n) }
|
|
84
|
+
for (const h of room.hostedOnline()) people.add(h.name)
|
|
85
|
+
const total = Object.keys(room.meta.members || {}).length + 1 // members, and the owner
|
|
86
|
+
const names = [...people].sort()
|
|
87
|
+
if (!names.length) return `Nobody is connected right now (${plural(total, 'member')} in the session).`
|
|
88
|
+
return `${plural(names.length, 'member')} connected of ${total}: ${list(names)}.`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function branchesAnswer (room, now) {
|
|
92
|
+
const on = whoIsOn(room)
|
|
93
|
+
const keys = Object.keys(room.meta.branches)
|
|
94
|
+
const lines = keys.sort((a, b) => (b === room.defaultKey) - (a === room.defaultKey) || a.localeCompare(b)).map((k) => {
|
|
95
|
+
const b = room.meta.branches[k]
|
|
96
|
+
const here = on.get(k) || []
|
|
97
|
+
const tag = k === room.defaultKey ? ' (default)' : ''
|
|
98
|
+
return `- ${k}${tag}: ${here.length ? `${list(here)} on it` : `nobody on it${b.used ? `, last used ${ago(b.used, now)}` : ''}`}`
|
|
99
|
+
})
|
|
100
|
+
return keys.length === 1 ? `1 branch tracked: ${lines[0].slice(2)}.` : `${plural(keys.length, 'branch')} tracked:\n${lines.join('\n')}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The branches trim would remove: not the default, nobody on it or holding a file there, no
|
|
105
|
+
* hosted agent's choice, and no member on it for TRIM_IDLE_MS. A branch from before `used` was
|
|
106
|
+
* kept counts from its last `seen` (the relay's own saves move that too, so it only waits longer).
|
|
107
|
+
*/
|
|
108
|
+
export function unusedBranches (room, now = Date.now()) {
|
|
109
|
+
const on = whoIsOn(room)
|
|
110
|
+
const claimed = new Set(Object.values(room.meta.claims || {}).map((c) => room.branchOf(c)))
|
|
111
|
+
const chosen = new Set(Object.values(room.meta.hostedBranch || {}))
|
|
112
|
+
return Object.entries(room.meta.branches)
|
|
113
|
+
.filter(([k, b]) => k !== room.defaultKey && !on.has(k) && !claimed.has(k) && !chosen.has(k) && now - (b.used || b.seen || b.at || 0) >= TRIM_IDLE_MS)
|
|
114
|
+
.map(([k]) => k)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function trimAnswer (room, asker, now) {
|
|
118
|
+
if (!asker.mayTrim) return 'Only the session owner can trim branches.'
|
|
119
|
+
const gone = unusedBranches(room, now)
|
|
120
|
+
if (!gone.length) return 'Nothing to trim: every branch is in use or was used in the last day.'
|
|
121
|
+
for (const k of gone) room.removeBranch(k, { save: false })
|
|
122
|
+
room.saveMeta()
|
|
123
|
+
room.broadcastBranches()
|
|
124
|
+
room.log(`[${room.name}] ${asker.name} trimmed ${plural(gone.length, 'unused branch')}: ${gone.join(', ')}`)
|
|
125
|
+
return `Trimmed ${plural(gone.length, 'branch')} nobody used for a day: ${list(gone)}. Git keeps them, and a branch comes back when someone checks it out.`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const HELP = `I can answer:\n${RELAY_QUESTIONS.map((q) => `- ${q.text}${q.owner ? ' (owner only)' : ''}`).join('\n')}`
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The relay's answer to message `m`. `asker`: { name, mayTrim }. Returns the text.
|
|
132
|
+
*/
|
|
133
|
+
export function relayAnswer (room, m, asker, now = Date.now()) {
|
|
134
|
+
switch (questionOf(m.text)) {
|
|
135
|
+
case 'git': return gitAnswer(room, now)
|
|
136
|
+
case 'members': return membersAnswer(room)
|
|
137
|
+
case 'branches': return branchesAnswer(room, now)
|
|
138
|
+
case 'trim': return trimAnswer(room, asker, now)
|
|
139
|
+
default: return HELP
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/relay-ci.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// CI on GitHub becomes work on the board: checks failing on a branch the session follows open one task,
|
|
2
|
+
// "CI failing on <branch>", for the member whose commit it is (when they are in the session); a newer commit
|
|
3
|
+
// failing too is noted on it; checks passing again move it to Done. One task per branch (its id comes from the
|
|
4
|
+
// branch), so a run reported twice never adds a second.
|
|
5
|
+
import crypto from 'node:crypto'
|
|
6
|
+
import { readTasks, addTask, updateTask } from './tasks.js'
|
|
7
|
+
import { addComment } from './task-comments.js'
|
|
8
|
+
import { githubWriter } from './github-commit.js'
|
|
9
|
+
import { credentialsFor } from './relay-commit.js'
|
|
10
|
+
import { upstreamsOf, parseRepo } from './relay-upstream.js'
|
|
11
|
+
import { memberForAuthor, everyone } from './relay-clash.js'
|
|
12
|
+
import { runGit } from './relay-git.js'
|
|
13
|
+
import { RELAY_BY } from './branches.js'
|
|
14
|
+
|
|
15
|
+
const ORIGIN = 'relay-publish'
|
|
16
|
+
// What GitHub calls a check suite that failed (check_suite.conclusion).
|
|
17
|
+
const FAILED = new Set(['failure', 'timed_out', 'action_required', 'startup_failure'])
|
|
18
|
+
|
|
19
|
+
/** The task id for CI on `branch` of this session: the same every time. */
|
|
20
|
+
export const ciTaskId = (room, branch) => crypto.createHash('sha256').update(`ci\0${room.name}\0${branch}`).digest('hex').slice(0, 16)
|
|
21
|
+
|
|
22
|
+
/** Who made commit `sha` (from the relay's mirror): the member it maps to, or ''. */
|
|
23
|
+
async function authorOf (room, rec, sha) {
|
|
24
|
+
if (!room.mirrors) return ''
|
|
25
|
+
try {
|
|
26
|
+
const repo = parseRepo(`https://${rec.repo}`)
|
|
27
|
+
const dir = (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
|
|
28
|
+
const r = await runGit(dir, ['log', '-1', '--format=%an%x1f%ae', sha])
|
|
29
|
+
if (!r.ok) return ''
|
|
30
|
+
const [name, email] = r.out.trim().split('\x1f')
|
|
31
|
+
// By their noreply address, else by name: a member on record, or one connected now.
|
|
32
|
+
const online = room.names ? [...room.names.values()] : []
|
|
33
|
+
return memberForAuthor(room, { name, email }) || everyone(room).find((m) => m.name === name)?.name || online.find((n) => n === name) || ''
|
|
34
|
+
} catch { return '' }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A check suite on `repo` finished at commit `sha` of `branch` with `conclusion`. When the session follows that
|
|
39
|
+
* branch: failing opens (or reopens, or notes) its CI task; passing moves an open one to Done. Returns what it did
|
|
40
|
+
* ('opened', 'noted', 'closed') or null. Never throws.
|
|
41
|
+
*/
|
|
42
|
+
export function noteCiResult (room, result, opts = {}) {
|
|
43
|
+
// One at a time per branch, in the order GitHub's results arrive: a failure takes longer to note (GitHub is asked
|
|
44
|
+
// which checks failed), and a pass right after it must not be noted first.
|
|
45
|
+
if (!room.ciQueue) room.ciQueue = new Map()
|
|
46
|
+
const k = String(result && result.branch)
|
|
47
|
+
const next = (room.ciQueue.get(k) || Promise.resolve()).then(() => noteCiResultNow(room, result, opts))
|
|
48
|
+
room.ciQueue.set(k, next.catch(() => null))
|
|
49
|
+
return next
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether commit `older` is an ancestor of `newer` in the relay's mirror of `rec`'s repository (false when it can't say). */
|
|
53
|
+
async function before (room, rec, older, newer) {
|
|
54
|
+
try {
|
|
55
|
+
const repo = parseRepo(`https://${rec.repo}`)
|
|
56
|
+
const dir = (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
|
|
57
|
+
return (await runGit(dir, ['merge-base', '--is-ancestor', older, newer])).ok
|
|
58
|
+
} catch { return false }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function noteCiResultNow (room, { repo, sha, branch, conclusion }, { fetch = globalThis.fetch, log = () => {} } = {}) {
|
|
62
|
+
try {
|
|
63
|
+
const want = String(repo).toLowerCase().replace(/^github\.com\//, '')
|
|
64
|
+
const rec = Object.values(upstreamsOf(room)).find((r) => r && r.repo && r.ref === branch && String(r.repo).toLowerCase().replace(/^github\.com\//, '') === want)
|
|
65
|
+
if (!rec || !/^[0-9a-f]{40}$/.test(sha || '')) return null
|
|
66
|
+
const failed = FAILED.has(String(conclusion))
|
|
67
|
+
if (!failed && conclusion !== 'success') return null
|
|
68
|
+
// A failure on an older commit than one that passed since (GitHub's results can come out of order) is old news.
|
|
69
|
+
if (!room.ciPassed) room.ciPassed = new Map()
|
|
70
|
+
const passed = room.ciPassed.get(branch)
|
|
71
|
+
if (!failed) room.ciPassed.set(branch, sha)
|
|
72
|
+
else if (passed && passed !== sha && room.mirrors && await before(room, rec, sha, passed)) return null
|
|
73
|
+
const map = room.doc.getMap('tasks')
|
|
74
|
+
const id = ciTaskId(room, branch)
|
|
75
|
+
const task = readTasks(map).find((t) => t.id === id)
|
|
76
|
+
const open = task && !task.archived && task.column !== 'done'
|
|
77
|
+
const comment = (text) => addComment(room.doc, room.doc.getMap('taskComments'), readTasks(map), { taskId: id, by: RELAY_BY, text }, ORIGIN)
|
|
78
|
+
if (!failed) {
|
|
79
|
+
if (!open) return null
|
|
80
|
+
comment(`CI passes again on ${branch} at ${sha.slice(0, 7)}.`)
|
|
81
|
+
updateTask(room.doc, map, { id, column: 'done' }, ORIGIN)
|
|
82
|
+
return 'closed'
|
|
83
|
+
}
|
|
84
|
+
// Which checks failed, when GitHub says (a failing suite is enough to act on).
|
|
85
|
+
let names = []
|
|
86
|
+
try {
|
|
87
|
+
const r = parseRepo(`https://${rec.repo}`)
|
|
88
|
+
const c = await githubWriter({ fetch, token: await credentialsFor(room, r), owner: r.owner, name: r.name }).checks(sha)
|
|
89
|
+
names = c.failing || []
|
|
90
|
+
} catch (err) { log(`[${room.name}] ${branch}: could not read which checks failed on ${sha.slice(0, 7)}: ${err.message}`) }
|
|
91
|
+
const what = names.length ? names.slice(0, 5).join(', ') : 'checks'
|
|
92
|
+
// The relay shut down while GitHub was asked: the room notes it when it is back (server.js runCi).
|
|
93
|
+
if (room.destroyed) return null
|
|
94
|
+
const url = `https://${rec.repo}/commit/${sha}`
|
|
95
|
+
if (open) {
|
|
96
|
+
comment(`Still failing at ${sha.slice(0, 7)}: ${what}. ${url}`)
|
|
97
|
+
return 'noted'
|
|
98
|
+
}
|
|
99
|
+
const who = await authorOf(room, rec, sha)
|
|
100
|
+
if (room.destroyed) return null
|
|
101
|
+
const title = `CI failing on ${branch}: ${what}`
|
|
102
|
+
if (task) updateTask(room.doc, map, { id, title, column: 'todo', archived: false, ...(who ? { assignee: who } : {}) }, ORIGIN)
|
|
103
|
+
else addTask(room.doc, map, { id, title, by: RELAY_BY, assignee: who, column: 'todo' }, ORIGIN)
|
|
104
|
+
comment(`GitHub's checks failed on ${branch} at ${sha.slice(0, 7)}: ${what}. ${url}${who ? ` The commit is ${who}'s.` : ''}`)
|
|
105
|
+
log(`[${room.name}] ${branch}: CI failing at ${sha.slice(0, 7)} (${what}): task ${id}${who ? ` for ${who}` : ''}`)
|
|
106
|
+
return 'opened'
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log(`[${room.name}] ${branch}: could not note CI on ${String(sha).slice(0, 7)}: ${err.message}`)
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
}
|