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.
Files changed (166) hide show
  1. package/RELEASES.md +279 -17
  2. package/bin/quilt.js +62 -152
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -2
  5. package/src/account.js +0 -29
  6. package/src/agent-task-workflow.js +2 -2
  7. package/src/agents/common.js +13 -0
  8. package/src/ai-vm.js +9 -1
  9. package/src/api/activity.js +5 -0
  10. package/src/api/memory-store.js +16 -38
  11. package/src/api/notify.js +134 -0
  12. package/src/api/routes/browsers.js +29 -0
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/session-migrate.js +7 -4
  15. package/src/api/routes/sessions.js +73 -1
  16. package/src/api/routes/teams.js +0 -23
  17. package/src/api/server.js +17 -25
  18. package/src/api/supabase-store.js +43 -56
  19. package/src/autowork.js +182 -16
  20. package/src/branch-view.js +6 -131
  21. package/src/connection.js +36 -9
  22. package/src/control.js +131 -48
  23. package/src/duties.js +1 -1
  24. package/src/force-date.js +27 -0
  25. package/src/fsutil.js +13 -3
  26. package/src/github-commit.js +3 -315
  27. package/src/history.js +13 -3
  28. package/src/mcp.js +93 -167
  29. package/src/merge3.js +11 -3
  30. package/src/now-summary.js +113 -0
  31. package/src/pathrules.js +12 -0
  32. package/src/protocol.js +10 -2
  33. package/src/quilt-default.js +84 -0
  34. package/src/quilt-history.js +106 -0
  35. package/src/quilt-remote.js +78 -0
  36. package/src/relay-ask.js +3 -66
  37. package/src/relay-cluster.js +3 -5
  38. package/src/relay-lease.js +6 -2
  39. package/src/relay-mcp.js +154 -432
  40. package/src/relay-prune.js +6 -145
  41. package/src/relay-turn-sync.js +1 -1
  42. package/src/release-words.js +2 -2
  43. package/src/runner.js +20 -7
  44. package/src/seam-access.js +97 -0
  45. package/src/seam-claims.js +53 -0
  46. package/src/seam-url.js +15 -0
  47. package/src/server.js +1516 -1383
  48. package/src/session-follow.js +461 -27
  49. package/src/session-layout3.js +410 -4
  50. package/src/session-migrate.js +15 -11
  51. package/src/session-quilt.js +1 -1
  52. package/src/session-seam.js +233 -20
  53. package/src/session.js +745 -3438
  54. package/src/source-live/bring-in.js +283 -0
  55. package/src/source-live/live-block.js +504 -33
  56. package/src/source-live/live-square.js +16 -5
  57. package/src/status.js +7 -58
  58. package/src/turn-sync.js +15 -2
  59. package/src/ui/agent-guide.js +1 -1
  60. package/src/ui/app.css +85 -20
  61. package/src/ui/app.js +4 -1
  62. package/src/ui/changes.js +5 -14
  63. package/src/ui/chat.js +9 -4
  64. package/src/ui/cloud-ai-model.js +13 -0
  65. package/src/ui/cloud-ai.js +10 -2
  66. package/src/ui/common.js +6 -4
  67. package/src/ui/fileview.js +20 -7
  68. package/src/ui/force-words.js +19 -0
  69. package/src/ui/git-rule.js +9 -28
  70. package/src/ui/home.js +3 -223
  71. package/src/ui/loom-model.js +15 -1
  72. package/src/ui/quiltview.js +213 -0
  73. package/src/ui/seam-model.js +251 -0
  74. package/src/ui/session.js +377 -489
  75. package/src/ui/tree.js +2 -8
  76. package/src/ui/voice-call.js +83 -14
  77. package/src/ui/voice-model.js +39 -0
  78. package/src/ui/voice.js +167 -17
  79. package/src/ui/workspaces.js +2 -122
  80. package/src/ui-server.js +81 -66
  81. package/src/update-check.js +25 -1
  82. package/src/voice.js +13 -6
  83. package/src/api/routes/github.js +0 -393
  84. package/src/branches.js +0 -194
  85. package/src/clash.js +0 -128
  86. package/src/commit-request.js +0 -59
  87. package/src/commit.js +0 -213
  88. package/src/git-hook.js +0 -48
  89. package/src/git.js +0 -147
  90. package/src/gitignore.js +0 -49
  91. package/src/gitstate.js +0 -1033
  92. package/src/quilts/AGENTS.md +0 -1
  93. package/src/quilts/CLAUDE.md +0 -1
  94. package/src/quilts/accounts.js +0 -136
  95. package/src/quilts/affected.js +0 -132
  96. package/src/quilts/archiver.js +0 -175
  97. package/src/quilts/autorevert.js +0 -49
  98. package/src/quilts/bisect-run.js +0 -31
  99. package/src/quilts/blame.js +0 -103
  100. package/src/quilts/block.js +0 -347
  101. package/src/quilts/bucket.js +0 -25
  102. package/src/quilts/cbor.js +0 -92
  103. package/src/quilts/checks.js +0 -308
  104. package/src/quilts/claims.js +0 -144
  105. package/src/quilts/cli.js +0 -536
  106. package/src/quilts/client.js +0 -137
  107. package/src/quilts/conflict-format.js +0 -190
  108. package/src/quilts/describe.js +0 -296
  109. package/src/quilts/dogfood.js +0 -82
  110. package/src/quilts/errors.js +0 -8
  111. package/src/quilts/events.js +0 -171
  112. package/src/quilts/export-git.js +0 -130
  113. package/src/quilts/export-remote.js +0 -156
  114. package/src/quilts/export.js +0 -81
  115. package/src/quilts/fastmerge.js +0 -41
  116. package/src/quilts/hash.js +0 -44
  117. package/src/quilts/history.js +0 -266
  118. package/src/quilts/ids.js +0 -12
  119. package/src/quilts/import-follow.js +0 -236
  120. package/src/quilts/import-git.js +0 -294
  121. package/src/quilts/import.js +0 -196
  122. package/src/quilts/index.js +0 -94
  123. package/src/quilts/keys.js +0 -186
  124. package/src/quilts/limits.js +0 -67
  125. package/src/quilts/lines.js +0 -49
  126. package/src/quilts/live.js +0 -50
  127. package/src/quilts/merge-smart.js +0 -114
  128. package/src/quilts/merge.js +0 -501
  129. package/src/quilts/oauth.js +0 -202
  130. package/src/quilts/objects.js +0 -144
  131. package/src/quilts/pack.js +0 -118
  132. package/src/quilts/records.js +0 -211
  133. package/src/quilts/resolutions.js +0 -69
  134. package/src/quilts/reviews.js +0 -208
  135. package/src/quilts/routes.js +0 -186
  136. package/src/quilts/runner.js +0 -95
  137. package/src/quilts/server.js +0 -144
  138. package/src/quilts/service.js +0 -963
  139. package/src/quilts/setup.js +0 -41
  140. package/src/quilts/signing.js +0 -149
  141. package/src/quilts/squares.js +0 -239
  142. package/src/quilts/store-memory.js +0 -844
  143. package/src/quilts/store-rpc.js +0 -90
  144. package/src/quilts/store-supabase.js +0 -13
  145. package/src/quilts/tags.js +0 -64
  146. package/src/quilts/textdiff.js +0 -96
  147. package/src/quilts/tokens.js +0 -66
  148. package/src/quilts/tools.js +0 -403
  149. package/src/quilts/tree.js +0 -351
  150. package/src/quilts/webhook.js +0 -116
  151. package/src/quilts/window.js +0 -121
  152. package/src/quilts/xdiff.js +0 -435
  153. package/src/relay-ci.js +0 -111
  154. package/src/relay-clash.js +0 -214
  155. package/src/relay-commit.js +0 -918
  156. package/src/relay-filekey.js +0 -46
  157. package/src/relay-git.js +0 -274
  158. package/src/relay-githttp.js +0 -166
  159. package/src/relay-github.js +0 -109
  160. package/src/relay-outbound.js +0 -1295
  161. package/src/relay-repo-moves.js +0 -43
  162. package/src/relay-sync.js +0 -795
  163. package/src/relay-upstream.js +0 -889
  164. package/src/sync-words.js +0 -42
  165. package/src/ui/branches.js +0 -111
  166. 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: 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
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 { chosenBranches } from './relay-prune.js'
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)}. Git keeps them, and a branch comes back when someone checks it out.`
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)
@@ -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, { fileKey = '' } = {}) {
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): git, chat files, large files, chat links. */
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 = /^\/git\/([A-Za-z0-9_-]{1,64})\.git\//.exec(pathname) ||
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] : ''
@@ -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 (git_sync_leases), which works across relay
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
- const res = await fetch(`${String(apiUrl).replace(/\/+$/, '')}/v1/relay/leases/${what}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` }, body: JSON.stringify(body) })
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
  }