heyquilt 0.3.122 → 0.3.140
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/RELEASES.md +279 -17
- package/bin/quilt.js +62 -152
- package/desktop/main.js +16 -2
- package/package.json +3 -2
- package/src/account.js +0 -29
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +16 -38
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/session-migrate.js +7 -4
- package/src/api/routes/sessions.js +73 -1
- package/src/api/routes/teams.js +0 -23
- package/src/api/server.js +17 -25
- package/src/api/supabase-store.js +43 -56
- package/src/autowork.js +182 -16
- package/src/branch-view.js +6 -131
- package/src/connection.js +36 -9
- package/src/control.js +131 -48
- package/src/duties.js +1 -1
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/github-commit.js +3 -315
- package/src/history.js +13 -3
- package/src/mcp.js +93 -167
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-ask.js +3 -66
- package/src/relay-cluster.js +3 -5
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +154 -432
- package/src/relay-prune.js +6 -145
- package/src/relay-turn-sync.js +1 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1516 -1383
- package/src/session-follow.js +461 -27
- package/src/session-layout3.js +410 -4
- package/src/session-migrate.js +15 -11
- package/src/session-quilt.js +1 -1
- package/src/session-seam.js +233 -20
- package/src/session.js +745 -3438
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +504 -33
- package/src/source-live/live-square.js +16 -5
- package/src/status.js +7 -58
- package/src/turn-sync.js +15 -2
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/changes.js +5 -14
- package/src/ui/chat.js +9 -4
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -4
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -28
- package/src/ui/home.js +3 -223
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +377 -489
- package/src/ui/tree.js +2 -8
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui/workspaces.js +2 -122
- package/src/ui-server.js +81 -66
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/api/routes/github.js +0 -393
- package/src/branches.js +0 -194
- package/src/clash.js +0 -128
- package/src/commit-request.js +0 -59
- package/src/commit.js +0 -213
- package/src/git-hook.js +0 -48
- package/src/git.js +0 -147
- package/src/gitignore.js +0 -49
- package/src/gitstate.js +0 -1033
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/accounts.js +0 -136
- package/src/quilts/affected.js +0 -132
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/checks.js +0 -308
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -536
- package/src/quilts/client.js +0 -137
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -296
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -171
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export-remote.js +0 -156
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-follow.js +0 -236
- package/src/quilts/import-git.js +0 -294
- package/src/quilts/import.js +0 -196
- package/src/quilts/index.js +0 -94
- package/src/quilts/keys.js +0 -186
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -501
- package/src/quilts/oauth.js +0 -202
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -208
- package/src/quilts/routes.js +0 -186
- package/src/quilts/runner.js +0 -95
- package/src/quilts/server.js +0 -144
- package/src/quilts/service.js +0 -963
- package/src/quilts/setup.js +0 -41
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -844
- package/src/quilts/store-rpc.js +0 -90
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -403
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -121
- package/src/quilts/xdiff.js +0 -435
- package/src/relay-ci.js +0 -111
- package/src/relay-clash.js +0 -214
- package/src/relay-commit.js +0 -918
- package/src/relay-filekey.js +0 -46
- package/src/relay-git.js +0 -274
- package/src/relay-githttp.js +0 -166
- package/src/relay-github.js +0 -109
- package/src/relay-outbound.js +0 -1295
- package/src/relay-repo-moves.js +0 -43
- package/src/relay-sync.js +0 -795
- package/src/relay-upstream.js +0 -889
- package/src/sync-words.js +0 -42
- package/src/ui/branches.js +0 -111
- package/src/upstream.js +0 -192
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// New sessions are quilts from the start (Seam's plans/quilt-source-control/16, flags order 8,
|
|
2
|
+
// QUILT_SOURCE_DEFAULT=1 on the accounts API): the app that starts a session (the desktop app,
|
|
3
|
+
// `quilt start`, or an AI's quilt_start_session; every tool goes through the same Session) makes it
|
|
4
|
+
// a quilt without history as soon as the folder's files have reached the relay: patch 1 holds them
|
|
5
|
+
// (binaries and modes too), and the room moves to layout 3 with a square for the folder.
|
|
6
|
+
//
|
|
7
|
+
// Nothing is lost when it can't: a folder with a git remote is not made a quilt here (16: a room
|
|
8
|
+
// with a git remote becomes a quilt only with its history, which flag 9's "Make this a quilt"
|
|
9
|
+
// starts), and any failure leaves the session a working git-mode session, with the reason said to
|
|
10
|
+
// the owner and their AIs and `quilt session migrate` to try again by hand.
|
|
11
|
+
import { execFileSync } from 'node:child_process'
|
|
12
|
+
import { apiUrl } from './account.js'
|
|
13
|
+
|
|
14
|
+
const FEATURES_TIMEOUT_MS = 5000
|
|
15
|
+
const SYNC_TRIES = 20
|
|
16
|
+
const SYNC_WAIT_MS = 500
|
|
17
|
+
|
|
18
|
+
/** Whether the accounts API at `api` has new sessions start as quilts (GET /v1/features `quilt_source_default`). False when it can't say. */
|
|
19
|
+
export async function quiltDefaultOn ({ api = apiUrl(), fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetchImpl(`${String(api).replace(/\/+$/, '')}/v1/features`, { signal: AbortSignal.timeout(FEATURES_TIMEOUT_MS) })
|
|
22
|
+
if (!res.ok) return false
|
|
23
|
+
const f = await res.json()
|
|
24
|
+
return !!(f && f.quilt_source_default === true)
|
|
25
|
+
} catch { return false }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether the folder at `root` is a git repository with a remote (its history belongs in the quilt: 16 "With history"). */
|
|
29
|
+
export function folderHasGitRemote (root) {
|
|
30
|
+
try {
|
|
31
|
+
return execFileSync('git', ['--no-optional-locks', 'remote'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }).trim().length > 0
|
|
32
|
+
} catch { return false }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Makes the new session `session` a quilt (migrate, then the layout-3 switch) as its owner, once.
|
|
39
|
+
* Resolves to { done: true, quilt } or { done: false, why }; never throws.
|
|
40
|
+
*/
|
|
41
|
+
export async function startAsQuilt (session, { flagOn = quiltDefaultOn, credential, hasRemote = folderHasGitRemote, syncTries = SYNC_TRIES, syncWaitMs = SYNC_WAIT_MS } = {}) {
|
|
42
|
+
const skip = (why) => ({ done: false, why })
|
|
43
|
+
try {
|
|
44
|
+
if (!(await flagOn())) return skip('off')
|
|
45
|
+
if (session.stopped) return skip('stopped')
|
|
46
|
+
if (session.quilt) return skip('already a quilt')
|
|
47
|
+
if (session.root && hasRemote(session.root)) {
|
|
48
|
+
session.log('🧵 this folder has a git remote, so the new session stays on git: it becomes a quilt with its history later (Make this a quilt).')
|
|
49
|
+
return skip('git remote')
|
|
50
|
+
}
|
|
51
|
+
if (session.branchForm === 'diff') return skip('diff branch')
|
|
52
|
+
// The folder's files are its patch 1: they must all be on the relay first.
|
|
53
|
+
let synced = false
|
|
54
|
+
for (let i = 0; i < syncTries && !synced && !session.stopped; i++) {
|
|
55
|
+
if (session.ready && !session.seeding) {
|
|
56
|
+
try { session.flushPending?.() } catch {}
|
|
57
|
+
try { await session.conn.confirmBranch(); synced = true; break } catch {}
|
|
58
|
+
}
|
|
59
|
+
await wait(syncWaitMs)
|
|
60
|
+
}
|
|
61
|
+
if (session.stopped) return skip('stopped')
|
|
62
|
+
if (!synced) throw new Error("the folder's files had not all reached the relay")
|
|
63
|
+
if (session.quilt) return skip('already a quilt')
|
|
64
|
+
const token = await credential()
|
|
65
|
+
if (!token) throw new Error('no sign-in to reach Seam with')
|
|
66
|
+
const m = await session.quiltMigrate(token)
|
|
67
|
+
await session.quiltSwitch(token)
|
|
68
|
+
// The folder equals patch 1, so the switch opened no square for it: one is opened now, as the
|
|
69
|
+
// owner, and the folder follows it (16: a square for the session's first folder). Should that
|
|
70
|
+
// fail, the session is a quilt still, and the folder's first edit asks for its square (readOnly).
|
|
71
|
+
for (let i = 0; i < syncTries * 4 && !session.stopped && !(session.quilt && session.quilt.layout === 3); i++) await wait(syncWaitMs / 4)
|
|
72
|
+
const branch = typeof session.branchKey === 'function' ? session.branchKey() : session.branch
|
|
73
|
+
if (session.quilt && session.quilt.layout === 3 && !(session.quilt.live || {})[branch]) {
|
|
74
|
+
try { await session.openBranchSquare(branch, { quiet: false, token }) } catch (err) { session.log(`🧵 couldn't open a square for this folder yet (${err.message}); its first edit asks again.`) }
|
|
75
|
+
}
|
|
76
|
+
session.log(`🧵 new session: its code is in Seam from the start (quilt ${(m.quilt && m.quilt.slug) || ''}, patch 1).`)
|
|
77
|
+
return { done: true, quilt: m.quilt }
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const why = String((err && err.message) || err)
|
|
80
|
+
session.log(`🧵 this new session's code could not go into Seam (${why}); the session works as before, on git. The owner can try again with: quilt session migrate`)
|
|
81
|
+
if (session.notice) { try { session.notice(`This new session's code could not go into Seam (${why}), so it stays on git for now. The session owner can try again with \`quilt session migrate\`.`) } catch {} }
|
|
82
|
+
return skip(why)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// quilt_history in a session linked to a quilt (Seam's plans/quilt-source-control/16, step 1
|
|
2
|
+
// "Shadow", and after the session became a quilt): the chronology is Seam's log (the commits and
|
|
3
|
+
// patches, imported from git or made in Seam) plus the session's own live record of changes not
|
|
4
|
+
// committed yet (the branch document's `history`, history.js), oldest first, newest kept.
|
|
5
|
+
//
|
|
6
|
+
// Quilt reads Seam over its HTTP API (a client with the @heyquilt/seam quiltClient shape: `log`,
|
|
7
|
+
// `show`), as the member (Session.seamMember) or, on the relay, with the room's read token. When
|
|
8
|
+
// Seam can't be read, the session's own record is shown with a note, never an error: the tool
|
|
9
|
+
// worked before the link and keeps working without Seam. The same for every AI tool: this runs
|
|
10
|
+
// behind quilt_history in both MCP servers (mcp.js through the folder's daemon, relay-mcp.js).
|
|
11
|
+
import { MAX_DIFF_CHARS, queryHistory } from './history.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The quilt a room's chronology reads from: `{ id, slug, at?, shadow? }`, or null. A quilt the
|
|
15
|
+
* session became (meta.quilt_id), or the quilt it shadows while git is still the authority
|
|
16
|
+
* (meta.quiltShadow, 16 step 1: an import following the session's git remote). Only with Seam
|
|
17
|
+
* on (QUILT_SOURCE_URL), so every other session reads its own record exactly as before.
|
|
18
|
+
*/
|
|
19
|
+
export function historyQuilt (meta, env = process.env) {
|
|
20
|
+
if (!env || !env.QUILT_SOURCE_URL || !meta) return null
|
|
21
|
+
if (typeof meta.quilt_id === 'string' && meta.quilt_id) {
|
|
22
|
+
const q = meta.quilt || {}
|
|
23
|
+
return { id: meta.quilt_id, slug: q.slug || '', ...(q.at ? { at: q.at } : {}) }
|
|
24
|
+
}
|
|
25
|
+
const s = meta.quiltShadow
|
|
26
|
+
if (s && typeof s.id === 'string' && s.id) return { id: s.id, slug: s.slug || '', shadow: true }
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The part of a path or glob Seam's log can filter by: a file, or the folder before the first wildcard. */
|
|
31
|
+
export function seamPathOf (p) {
|
|
32
|
+
const s = String(p || '').replace(/\\/g, '/').replace(/^\.\//, '')
|
|
33
|
+
if (!s) return ''
|
|
34
|
+
const wild = s.search(/[*?[{]/)
|
|
35
|
+
if (wild < 0) return s.replace(/\/+$/, '')
|
|
36
|
+
const dir = s.slice(0, wild)
|
|
37
|
+
return dir.includes('/') ? dir.slice(0, dir.lastIndexOf('/')) : ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MAX_SHOWN = 10 // commits whose diffs are read when asked for them
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The quilt's log as chronology entries, oldest first: one per commit, `{ seam: true, commit,
|
|
44
|
+
* short, patch, by, ts, message, files, task, diff }`. `since` is a timestamp. Throws what Seam
|
|
45
|
+
* said when it can't be read.
|
|
46
|
+
*/
|
|
47
|
+
export async function seamHistory (seam, quilt, { path = '', by, since, task, limit = 30, withDiff = false, rev = 'main' } = {}) {
|
|
48
|
+
const p = seamPathOf(path)
|
|
49
|
+
const r = await seam.log(quilt, { rev, limit: Math.min(Number(limit) || 30, 200), ...(p ? { paths: [p] } : {}), ...(by ? { author: by } : {}), ...(typeof since === 'number' ? { since: String(Math.floor(since)) } : {}), ...(task ? { task } : {}) })
|
|
50
|
+
const out = []
|
|
51
|
+
for (const c of (r && r.commits) || []) {
|
|
52
|
+
if (!c || !c.id) continue
|
|
53
|
+
const a = c.author || {}
|
|
54
|
+
out.push({
|
|
55
|
+
id: `seam:${c.id}`,
|
|
56
|
+
seam: true,
|
|
57
|
+
commit: c.id,
|
|
58
|
+
short: c.short || c.id.slice(0, 12),
|
|
59
|
+
...(c.patch && c.patch.patch_no != null ? { patch: { line: c.patch.line || rev, no: c.patch.patch_no } } : {}),
|
|
60
|
+
by: String(a.name || a.member_id || ''),
|
|
61
|
+
ts: Number(c.at ?? c.authored_at) || 0,
|
|
62
|
+
message: String(c.message || '').split('\n')[0].slice(0, 200),
|
|
63
|
+
files: Number(c.files) || (Array.isArray(c.changes) ? c.changes.length : 0),
|
|
64
|
+
task: c.task_id ? { id: String(c.task_id), title: '' } : null,
|
|
65
|
+
path: p,
|
|
66
|
+
diff: ''
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
if (withDiff) {
|
|
70
|
+
for (const e of out.slice(0, MAX_SHOWN)) {
|
|
71
|
+
try {
|
|
72
|
+
const s = await seam.show(quilt, e.commit, { format: 'unified', ...(p ? { paths: [p] } : {}) })
|
|
73
|
+
const text = ((s && s.diff && s.diff.files) || []).map((f) => f.patch || (f.binary ? `${f.path}: binary` : '')).filter(Boolean).join('\n')
|
|
74
|
+
e.diff = text.length > MAX_DIFF_CHARS ? `${text.slice(0, MAX_DIFF_CHARS)}\n… (diff truncated)` : text
|
|
75
|
+
} catch { e.diff = '' }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out.sort((x, y) => x.ts - y.ts)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The chronology of a linked session: the quilt's log (`seam`, a client) and the session's own
|
|
83
|
+
* record (`entries`, history.js), filtered by `query` (path, by, since timestamp, task, limit,
|
|
84
|
+
* withDiff), oldest first, the newest `limit` kept. A change git pulled into the session is the
|
|
85
|
+
* log's (an imported patch) and is left out of the session's record when the log answered, so it
|
|
86
|
+
* is not counted twice (16: "entries with `pulled` or `seed` are dropped", here only `pulled`:
|
|
87
|
+
* a seed is the session's first copy, which the log has no entry for). Returns { list, note }:
|
|
88
|
+
* `note` says why the log is missing, when it is.
|
|
89
|
+
*/
|
|
90
|
+
export async function linkedHistory ({ seam, quilt, entries = [], query = {} }) {
|
|
91
|
+
const { path = '', by, since, task, limit = 30, withDiff = false } = query
|
|
92
|
+
let fromSeam = []
|
|
93
|
+
let note = ''
|
|
94
|
+
if (!seam) note = `Seam's log for ${quilt.slug || quilt.id} was not read: the session has no read token for it (its owner links it again); this is the session's own record.`
|
|
95
|
+
else {
|
|
96
|
+
try { fromSeam = await seamHistory(seam, quilt.id, { path, by, since, task, limit, withDiff }) } catch (err) { note = `Seam's log for ${quilt.slug || quilt.id} could not be read (${String(err.message || err).slice(0, 200)}); this is the session's own record.` }
|
|
97
|
+
}
|
|
98
|
+
return { list: mergeLinked(entries, fromSeam, { ...query, logRead: !note }), note }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The session's own entries (filtered by `query`; git's pulls left out when `logRead`) with the log's, oldest first, the newest `limit`. */
|
|
102
|
+
export function mergeLinked (entries, fromSeam, { path = '', by, since, task, limit = 30, logRead = true } = {}) {
|
|
103
|
+
const own = queryHistory(logRead ? entries.filter((e) => !(e && e.pulled)) : entries, { path, by, since, task, limit: 0 })
|
|
104
|
+
const list = [...own, ...(fromSeam || [])].sort((a, b) => a.ts - b.ts)
|
|
105
|
+
return limit && list.length > limit ? list.slice(-limit) : list
|
|
106
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// "Make this a quilt" for a session with a git remote (Seam's plans/quilt-source-control/16, flag 9, and
|
|
2
|
+
// "An opt-in team's migration"): the steps the owner's one button runs, in order, each time it is
|
|
3
|
+
// clicked, from where the session stands. Pure: no I/O (test/unit/quilt-remote.test.js).
|
|
4
|
+
//
|
|
5
|
+
// 1. import Seam imports the GitHub repository and follows it (the owner's Seam GitHub App
|
|
6
|
+
// installation is the read credential; 16 step 1). The session stays git-mode.
|
|
7
|
+
// 2. link Once the import follows, the session reads the quilt's log (the shadow link).
|
|
8
|
+
// 3. cut-over In the owner's sitting: main must take pushes from Seam's export only (a ruleset only
|
|
9
|
+
// the repository's GitHub owner can add: plain steps), then the import is cut over
|
|
10
|
+
// with export to the same remote on, then the session moves in with its history.
|
|
11
|
+
|
|
12
|
+
export const SEAM_APP = { id: 5475977, slug: 'seam-by-heyquilt' }
|
|
13
|
+
|
|
14
|
+
/** owner/name of the session's GitHub remote, from its upstreams (the default branch's first), or null. */
|
|
15
|
+
export function githubRepoOf (upstreams, defaultKey) {
|
|
16
|
+
const all = Object.entries(upstreams || {})
|
|
17
|
+
const pick = all.find(([k]) => k === defaultKey) || all[0]
|
|
18
|
+
const m = pick && /^github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(\.git)?$/.exec(String((pick[1] && pick[1].repo) || ''))
|
|
19
|
+
return m ? m[1] : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Which of the owner's Seam GitHub installations reaches `repo`, or null. */
|
|
23
|
+
export function installationFor (gh, repo) {
|
|
24
|
+
const want = String(repo).toLowerCase()
|
|
25
|
+
return ((gh && gh.installations) || []).find((i) => (i.repos || []).some((r) => String(r.full_name || '').toLowerCase() === want)) || null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The step the session is at: 'import' (nothing yet), 'link' (importing), 'cutover' (linked to the quilt that follows). */
|
|
29
|
+
export function remoteStage ({ quiltImport = null, quiltShadow = null }) {
|
|
30
|
+
if (quiltShadow) return 'cutover'
|
|
31
|
+
if (quiltImport) return 'link'
|
|
32
|
+
return 'import'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether GitHub's rules for main (GET /repos/:o/:r/rules/branches/main) keep everyone but an app from pushing to it. */
|
|
36
|
+
export function mainGuarded (rules) {
|
|
37
|
+
return Array.isArray(rules) && rules.some((r) => r && r.type === 'update')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What the repository's GitHub owner does so only Seam pushes to main (16's cut-over checklist, step 5). */
|
|
41
|
+
export function rulesetSteps (repo) {
|
|
42
|
+
return [
|
|
43
|
+
`On GitHub, open https://github.com/${repo}/settings/rules and choose New ruleset, then New branch ruleset.`,
|
|
44
|
+
'Name it "Seam is the authority on main" and set Enforcement status to Active.',
|
|
45
|
+
`Under Bypass list, add the app ${SEAM_APP.slug} (Seam), set to Always allow.`,
|
|
46
|
+
'Under Target branches, add the default branch and the pattern release/**.',
|
|
47
|
+
'Turn on Restrict updates, Restrict deletions and Block force pushes, then Create.',
|
|
48
|
+
'Ask everyone to stop pushing to main and wait for CI\'s last run there. Then click Move into Seam now.'
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The ruleset as GitHub's API takes it (for an owner who prefers `gh api -X POST repos/<repo>/rulesets --input`). */
|
|
53
|
+
export function rulesetBody () {
|
|
54
|
+
return {
|
|
55
|
+
name: 'Seam is the authority on main',
|
|
56
|
+
target: 'branch',
|
|
57
|
+
enforcement: 'active',
|
|
58
|
+
bypass_actors: [{ actor_id: SEAM_APP.id, actor_type: 'Integration', bypass_mode: 'always' }],
|
|
59
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH', 'refs/heads/release/**'], exclude: [] } },
|
|
60
|
+
rules: [{ type: 'update' }, { type: 'deletion' }, { type: 'non_fast_forward' }]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What the owner does when no Seam GitHub installation of theirs reaches the repository. */
|
|
65
|
+
export function installSteps (repo, installUrl = `https://github.com/apps/${SEAM_APP.slug}/installations/new`) {
|
|
66
|
+
return [
|
|
67
|
+
'Sign in to Seam at https://seam.heyquilt.com with the same account you use for Quilt.',
|
|
68
|
+
`Connect GitHub there and install the Seam app on ${repo} (${installUrl}). Seam reads the repository with it, and later pushes main to it.`,
|
|
69
|
+
'Then click Make this a quilt again.'
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One error whose message is the plain steps, with the steps alone in `steps` (the app lists them). */
|
|
74
|
+
export function stepsError (lead, steps) {
|
|
75
|
+
const e = new Error(`${lead}\n${steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`)
|
|
76
|
+
e.steps = steps
|
|
77
|
+
return e
|
|
78
|
+
}
|
package/src/relay-ask.js
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
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:
|
|
3
|
-
// connected, which branches it keeps, and (for the owner) trimming branches nobody uses. No
|
|
2
|
+
// and it answers from what it knows: who is connected, which branches it keeps, and (for the owner) trimming branches nobody uses. No
|
|
4
3
|
// model runs here: each question is matched by its words and answered by code. Pure but for
|
|
5
4
|
// `room`, which the relay passes in (server.js answerRelay).
|
|
6
5
|
|
|
7
|
-
import { RELAY_BY, isRelay } from './branches.js'
|
|
8
6
|
import { mentioned } from './inbox.js'
|
|
9
|
-
import {
|
|
10
|
-
import { RELAY_QUESTIONS, RELAY_OLD_NAMES } from './ui/chat.js'
|
|
7
|
+
import { RELAY_QUESTIONS, RELAY_OLD_NAMES, RELAY_NAME as RELAY_BY, isRelay } from './ui/chat.js'
|
|
11
8
|
|
|
12
9
|
export { RELAY_QUESTIONS }
|
|
13
10
|
|
|
@@ -25,7 +22,6 @@ export function asksRelay (m) {
|
|
|
25
22
|
export function questionOf (text) {
|
|
26
23
|
const t = String(text || '').replace(/@(the relay|quilt)\b/gi, ' ').toLowerCase()
|
|
27
24
|
if (/\b(trim|prune|clean ?up|remove)\b/.test(t)) return 'trim'
|
|
28
|
-
if (/\b(git|github|upstream|commits?|behind|synced?)\b/.test(t)) return 'git'
|
|
29
25
|
if (/\b(members?|connected|online|who|people|agents?)\b/.test(t)) return 'members'
|
|
30
26
|
if (/\bbranch(es)?\b/.test(t)) return 'branches'
|
|
31
27
|
return 'help'
|
|
@@ -53,32 +49,6 @@ function whoIsOn (room) {
|
|
|
53
49
|
return on
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
function gitAnswer (room, now) {
|
|
57
|
-
const ups = room.meta.upstreams || {}
|
|
58
|
-
const lines = []
|
|
59
|
-
for (const key of Object.keys(room.meta.branches)) {
|
|
60
|
-
const r = ups[key]
|
|
61
|
-
if (!r || !r.name) continue
|
|
62
|
-
const parts = [`${key} follows ${r.name}${r.repo ? ` (${r.repo})` : ''}`]
|
|
63
|
-
if (r.sha) parts.push(`at ${r.sha.slice(0, 7)}${r.checkedAt ? `, checked ${ago(r.checkedAt, now)}` : ''}`)
|
|
64
|
-
if (r.brought && r.brought.at) parts.push(`brought in ${plural(r.brought.count || 0, 'commit')} ${ago(r.brought.at, now)}`)
|
|
65
|
-
if (r.clash && r.clash.task) parts.push(`waiting on merge task ${r.clash.task}`)
|
|
66
|
-
if (r.problem) parts.push(`problem: ${typeof r.problem === 'string' ? r.problem : r.problem.message || 'see the branch menu'}`)
|
|
67
|
-
const why = r.github && GITHUB_BLOCKED[r.github.state]
|
|
68
|
-
if (why) parts.push(`agents can't commit: ${why}`)
|
|
69
|
-
lines.push(parts.join('; '))
|
|
70
|
-
}
|
|
71
|
-
if (!lines.length) return 'No branch here follows a GitHub repository yet. It starts when a member with a git folder joins.'
|
|
72
|
-
return lines.length === 1 ? `${lines[0]}.` : `Against git:\n${lines.map((l) => `- ${l}`).join('\n')}`
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Why agents can't commit to a branch's repository, by its GitHub connection's state (relay-upstream.js noteConnection).
|
|
76
|
-
const GITHUB_BLOCKED = {
|
|
77
|
-
'not-connected': 'nobody here has connected GitHub',
|
|
78
|
-
'not-installed': 'the Quilt app isn\'t installed on the repository',
|
|
79
|
-
'no-access': 'the connected GitHub account can\'t write to it'
|
|
80
|
-
}
|
|
81
|
-
|
|
82
52
|
function membersAnswer (room) {
|
|
83
53
|
const people = new Set()
|
|
84
54
|
for (const ws of room.conns.keys()) { const n = room.names.get(ws); if (n) people.add(n) }
|
|
@@ -116,38 +86,6 @@ export function unusedBranches (room, now = Date.now()) {
|
|
|
116
86
|
}
|
|
117
87
|
|
|
118
88
|
/** Branch names that are never trimmed for being merged: the trunk itself, whatever the session calls its default. */
|
|
119
|
-
const TRUNKS = new Set(['main', 'master'])
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* The branches whose work is merged and can leave the session (Room.trimMergedBranches), so a
|
|
123
|
-
* session never fills its MAX_BRANCHES with finished work. A branch counts when its pull request
|
|
124
|
-
* was merged on GitHub (its upstream record has `merged`, from notePull, reviewPulls or a merge
|
|
125
|
-
* made in Quilt) and nothing was written on it in the session since (`changed` no later than the
|
|
126
|
-
* merge). Never the default branch, main or master, the branch it merged into, a branch someone
|
|
127
|
-
* is on, holds a file on, or a hosted agent chose, nor one with session edits still waiting to go
|
|
128
|
-
* to GitHub. Git keeps every branch: only the relay's copy goes.
|
|
129
|
-
*/
|
|
130
|
-
export function mergedBranches (room, now = Date.now()) {
|
|
131
|
-
const on = whoIsOn(room)
|
|
132
|
-
const claimed = new Set(Object.values(room.meta.claims || {}).map((c) => room.branchOf(c)))
|
|
133
|
-
const chosen = chosenBranches(room, now) // a hosted agent's choice holds a branch worked on this week
|
|
134
|
-
const ups = room.meta.upstreams || {}
|
|
135
|
-
const bases = new Set()
|
|
136
|
-
for (const rec of Object.values(ups)) if (rec && rec.merged && rec.merged.into) bases.add(room.resolveKey ? room.resolveKey(rec.merged.into) : rec.merged.into)
|
|
137
|
-
return Object.entries(room.meta.branches)
|
|
138
|
-
.filter(([k, b]) => {
|
|
139
|
-
const rec = ups[k]
|
|
140
|
-
const m = rec && rec.merged
|
|
141
|
-
if (!m || !m.into || !(m.at > 0)) return false
|
|
142
|
-
if (k === room.defaultKey || TRUNKS.has(k) || TRUNKS.has(rec.ref) || bases.has(k) || rec.ref === m.into) return false
|
|
143
|
-
if (on.has(k) || claimed.has(k) || chosen.has(k)) return false
|
|
144
|
-
const pending = room.meta.sync && room.meta.sync[k] && room.meta.sync[k].pending
|
|
145
|
-
if (pending && Object.keys(pending).length) return false // session edits not on GitHub yet (relay-outbound.js)
|
|
146
|
-
return !(b && b.changed > m.at) // written on after the merge: that work isn't merged
|
|
147
|
-
})
|
|
148
|
-
.map(([k]) => k)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
89
|
function trimAnswer (room, asker, now) {
|
|
152
90
|
if (!asker.mayTrim) return 'Only the session owner can trim branches.'
|
|
153
91
|
const gone = unusedBranches(room, now)
|
|
@@ -156,7 +94,7 @@ function trimAnswer (room, asker, now) {
|
|
|
156
94
|
room.saveMeta()
|
|
157
95
|
room.broadcastBranches()
|
|
158
96
|
room.log(`[${room.name}] ${asker.name} trimmed ${plural(gone.length, 'unused branch')}: ${gone.join(', ')}`)
|
|
159
|
-
return `Trimmed ${plural(gone.length, 'branch')} nobody used for a day: ${list(gone)}.
|
|
97
|
+
return `Trimmed ${plural(gone.length, 'branch')} nobody used for a day: ${list(gone)}. A branch comes back when someone checks it out.`
|
|
160
98
|
}
|
|
161
99
|
|
|
162
100
|
const HELP = `I can answer:\n${RELAY_QUESTIONS.map((q) => `- ${q.text}${q.owner ? ' (owner only)' : ''}`).join('\n')}`
|
|
@@ -166,7 +104,6 @@ const HELP = `I can answer:\n${RELAY_QUESTIONS.map((q) => `- ${q.text}${q.owner
|
|
|
166
104
|
*/
|
|
167
105
|
export function relayAnswer (room, m, asker, now = Date.now()) {
|
|
168
106
|
switch (questionOf(m.text)) {
|
|
169
|
-
case 'git': return gitAnswer(room, now)
|
|
170
107
|
case 'members': return membersAnswer(room)
|
|
171
108
|
case 'branches': return branchesAnswer(room, now)
|
|
172
109
|
case 'trim': return trimAnswer(room, asker, now)
|
package/src/relay-cluster.js
CHANGED
|
@@ -50,14 +50,13 @@ export function clusterConfig (opts = {}, env = process.env) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/** What cluster mode is missing; [] when it can run. */
|
|
53
|
-
export function clusterProblems (cc, cfg
|
|
53
|
+
export function clusterProblems (cc, cfg) {
|
|
54
54
|
if (!cc.on) return []
|
|
55
55
|
const out = []
|
|
56
56
|
if (!MACHINE_RE.test(cc.machineId)) out.push('FLY_MACHINE_ID (this machine\'s id)')
|
|
57
57
|
if (!cfg.apiUrl) out.push('QUILT_API_URL')
|
|
58
58
|
if (!cfg.relayApiSecret) out.push('RELAY_API_SECRET')
|
|
59
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
60
|
if (!cc.peerUrl) out.push('QUILT_PEER_URL or FLY_APP_NAME (how machines reach each other)')
|
|
62
61
|
return out
|
|
63
62
|
}
|
|
@@ -75,10 +74,9 @@ export function storedRooms (dataDir) {
|
|
|
75
74
|
return [...out].sort()
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
/** The room a request's path is about ('' for none):
|
|
77
|
+
/** The room a request's path is about ('' for none): chat files, large files, chat links. */
|
|
79
78
|
export function roomOfPath (pathname) {
|
|
80
|
-
const m = /^\/
|
|
81
|
-
/^\/files\/([A-Za-z0-9_-]{1,64})(?:\/|$)/.exec(pathname) ||
|
|
79
|
+
const m = /^\/files\/([A-Za-z0-9_-]{1,64})(?:\/|$)/.exec(pathname) ||
|
|
82
80
|
/^\/blobs\/([A-Za-z0-9_-]{1,64})\//.exec(pathname) ||
|
|
83
81
|
/^\/c\/([A-Za-z0-9_-]{1,64})\//.exec(pathname)
|
|
84
82
|
return m ? m[1] : ''
|
package/src/relay-lease.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// One live session per repository and branch (plan §8, addendum 16.6). The session that pushes
|
|
2
|
-
// repo@branch holds its lease in the accounts API (
|
|
2
|
+
// repo@branch holds its lease in the accounts API (relay_leases), which works across relay
|
|
3
3
|
// machines; the relay renews every lease it holds every LEASE_HEARTBEAT_MS, for every session active
|
|
4
4
|
// in the last 24 hours (loaded or not), and keeps the list on its disk so a restart picks them up.
|
|
5
5
|
// A lease lapses LEASE_TTL_MS after its last heartbeat (two missed: a crashed relay's).
|
|
@@ -52,9 +52,13 @@ function localStore (now) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** A lease's key in the accounts API (relay_leases): "<repo> <branch>". */
|
|
56
|
+
export const leaseKeyOf = (repo, branch) => `${repo} ${branch}`
|
|
57
|
+
|
|
55
58
|
function apiStore ({ apiUrl, secret, fetch }) {
|
|
56
59
|
const call = async (what, body) => {
|
|
57
|
-
|
|
60
|
+
// `key` (relay_leases, 16 R2) beside repo and branch, which an API from before R2 reads instead: the same lease either way.
|
|
61
|
+
const res = await fetch(`${String(apiUrl).replace(/\/+$/, '')}/v1/relay/leases/${what}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` }, body: JSON.stringify({ key: leaseKeyOf(body.repo, body.branch), ...body }) })
|
|
58
62
|
if (!res.ok) throw new Error(`the accounts API answered ${res.status}`)
|
|
59
63
|
return res.json()
|
|
60
64
|
}
|