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
@@ -1,41 +0,0 @@
1
- // A fast front for merge-smart.js's mergeSmart (whose node-diff3 LCS is quadratic: seconds on a
2
- // 7,000-line file). A clean line merge comes from the Myers hunks in textdiff.js (`nway`, the
3
- // same N-way merge the conflict format uses); only a clash goes to mergeSmart's ladder, and then
4
- // with the lines all three versions share at the start and end cut off, so it reads only the
5
- // region that differs. Same answer, same `how`, as mergeSmart for a clean merge; a clash is
6
- // mergeSmart's own answer on the middle, put back between the shared lines.
7
- import { mergeSmart } from './merge-smart.js'
8
- import { nway } from './conflict-format.js'
9
-
10
- export function fastMerge (base, ours, theirs) {
11
- const [o, a, b] = [base.split('\n'), ours.split('\n'), theirs.split('\n')]
12
- const regions = nway(o, [a, b])
13
- if (regions.every((r) => r.ok)) return { text: regions.flatMap((r) => r.ok).join('\n'), conflicts: [], how: 'lines' }
14
- // Every clash is two inserts at one place the base had nothing (07's `additive` step): ours
15
- // then theirs, shared leading lines once. Shared trailing lines are kept on both sides, not
16
- // once as 07 says: two added functions share their closing `}`, and taking it once dropped
17
- // one function's closing brace (found by real agents adding functions side by side).
18
- if (regions.every((r) => r.ok || (!r.base.length && r.sides.length === 2))) {
19
- return { text: regions.flatMap((r) => r.ok || additive(r.sides[0], r.sides[1])).join('\n'), conflicts: [], how: 'additive' }
20
- }
21
- // Shared lines at both ends, leaving at least one line in each middle.
22
- const min = Math.min(o.length, a.length, b.length) - 1
23
- let p = 0
24
- while (p < min && o[p] === a[p] && o[p] === b[p]) p++
25
- let s = 0
26
- while (s < min - p && o[o.length - 1 - s] === a[a.length - 1 - s] && o[o.length - 1 - s] === b[b.length - 1 - s]) s++
27
- if (!p && !s) return mergeSmart(base, ours, theirs)
28
- const mid = (x) => x.slice(p, x.length - s).join('\n')
29
- const m = mergeSmart(mid(o), mid(a), mid(b))
30
- if (m.text === '') return mergeSmart(base, ours, theirs) // zero lines or one empty line: ambiguous
31
- return { ...m, text: [...o.slice(0, p), m.text, ...o.slice(o.length - s)].join('\n') }
32
- }
33
-
34
- /** Two inserts at one place: ours, then what theirs adds after the leading lines both share. */
35
- function additive (a, b) {
36
- let pre = 0
37
- while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
38
- if (pre === b.length) return a
39
- if (pre === a.length) return b
40
- return [...a, ...b.slice(pre)]
41
- }
@@ -1,44 +0,0 @@
1
- // Ids for Quilt source control (plans/quilt-source-control/03): BLAKE3-256 with a
2
- // domain-separation context per kind of object, so bytes of one kind can never be presented
3
- // as another. Ids travel as 64 hex characters.
4
- import { blake3 } from '@noble/hashes/blake3.js'
5
-
6
- const enc = new TextEncoder()
7
- const contexts = new Map()
8
-
9
- /** The context bytes for a kind and format version, e.g. 'commit' → "quilt commit v1". */
10
- function context (kind) {
11
- let c = contexts.get(kind)
12
- if (!c) { c = enc.encode(`quilt ${kind} v1`); contexts.set(kind, c) }
13
- return c
14
- }
15
-
16
- export const KINDS = ['commit', 'blob', 'tree', 'chunk', 'pack', 'term', 'manifest']
17
-
18
- /** The id of `bytes` as an object of `kind`: BLAKE3 derive-key mode, 32 bytes, as hex. */
19
- // Not object kinds: keys made with their own contexts so they can never equal an object id.
20
- const KEY_KINDS = ['idempotency', 'resolution']
21
-
22
- /** A key (not an object id) in its own context, e.g. 'idempotency' → "quilt idempotency v1". */
23
- export function keyOf (kind, bytes) {
24
- if (!KEY_KINDS.includes(kind)) throw new Error(`unknown key kind ${kind}`)
25
- return Buffer.from(blake3(bytes, { context: context(kind) })).toString('hex')
26
- }
27
-
28
- export function idOf (kind, bytes) {
29
- if (!KINDS.includes(kind)) throw new Error(`unknown object kind ${kind}`)
30
- return Buffer.from(blake3(bytes, { context: context(kind) })).toString('hex')
31
- }
32
-
33
- export const ID = /^[0-9a-f]{64}$/
34
-
35
- /** Whether `s` looks like an id (64 lowercase hex). */
36
- export const isId = (s) => typeof s === 'string' && ID.test(s)
37
-
38
- /** An id as its 32 bytes. */
39
- export const idBytes = (hex) => Buffer.from(hex, 'hex')
40
-
41
- /** A quilt's shard (03): blake3 of its id's 16 bytes (plain hash mode), first byte mod 64. */
42
- export function shardOf (quiltId) {
43
- return blake3(Buffer.from(String(quiltId).replace(/-/g, ''), 'hex'))[0] % 64
44
- }
@@ -1,266 +0,0 @@
1
- // History tools (plans/quilt-source-control/08 and 12): log --follow (a file's history through its
2
- // renames), log -S (commits that changed how often a text appears), describe (the nearest tag),
3
- // merge-base (where two revisions last shared history), grep (lines at a revision) and bisect (the
4
- // server narrows good..bad; whoever runs the check, the seam CLI or an agent, marks each step).
5
- import { cleanPath, cleanPrefix, under } from './records.js'
6
- import { isText, lineStat } from './textdiff.js'
7
- import { QuiltError } from './service.js'
8
-
9
- const MAX_WALK = 100000
10
- const MAX_GREP_BYTES = 64 * 1024 * 1024
11
- const MAX_GREP_FILE = 2 * 1024 * 1024
12
- const MAX_SEARCH_QUILTS = 100
13
-
14
- /** A grep pattern as a RegExp, refusing the shapes that can backtrack for ever on one line. */
15
- export function grepPattern (pattern, { fixed = false, ignoreCase = false } = {}) {
16
- const p = String(pattern ?? '')
17
- if (!p) throw new QuiltError(400, 'bad_request', 'give a pattern')
18
- if (p.length > 500) throw new QuiltError(400, 'bad_request', 'a pattern is up to 500 characters')
19
- const src = fixed ? p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : p
20
- if (!fixed && (/\((?:[^()\\]|\\.)*[+*}](?:[^()\\]|\\.)*\)\s*[+*{]/.test(p) || /\\[1-9]/.test(p))) {
21
- throw new QuiltError(400, 'bad_request', `the pattern ${p} could run for ever (a repeated group that repeats, or a back-reference): simplify it, or pass fixed for plain text`)
22
- }
23
- try { return new RegExp(src, ignoreCase ? 'i' : '') } catch (err) { throw new QuiltError(400, 'bad_request', `bad pattern: ${err.message}`) }
24
- }
25
-
26
- const count = (text, needle) => { let n = 0; let i = 0; while ((i = text.indexOf(needle, i)) >= 0) { n++; i += needle.length } return n }
27
-
28
- export function historyOps (svc, { mergeBase: commonBase }) {
29
- const { quiltFor, resolve, rootAt, getCommit, snapshot, reader, readBlob, commitView, store } = svc.internals
30
- const baseLog = svc.log
31
-
32
- async function entryAt (q, commitId, p) {
33
- if (!commitId) return null
34
- return reader(q).lookup(await snapshot(q, commitId), p)
35
- }
36
-
37
- async function textOf (q, e, cache) {
38
- if (!e || !e.blob) return null
39
- if (cache.has(e.blob)) return cache.get(e.blob)
40
- const b = await readBlob(q, e.blob)
41
- const t = isText(b) ? b.toString('utf8') : null
42
- cache.set(e.blob, t)
43
- return t
44
- }
45
-
46
- /** The path `p` had before commit `c` added it: copy_from, a deleted file with the same content, or the most alike deleted text file. */
47
- async function renamedFrom (q, c, ch, texts) {
48
- if (ch.copy_from?.path) return ch.copy_from.path
49
- const parent = c.parents[0] || null
50
- if (!parent || await entryAt(q, parent, ch.path)) return null
51
- const gone = c.changes.filter((x) => x.op === 'delete')
52
- if (!gone.length) return null
53
- const before = await Promise.all(gone.map((x) => entryAt(q, parent, x.path)))
54
- const same = gone.find((x, i) => before[i]?.blob === ch.blob)
55
- if (same) return same.path
56
- const now = await textOf(q, { blob: ch.blob }, texts)
57
- if (now == null) return null
58
- let best = null; let score = 0.5
59
- for (let i = 0; i < gone.length; i++) {
60
- const was = await textOf(q, before[i], texts)
61
- if (was == null) continue
62
- const st = lineStat(was, now)
63
- const total = Math.max(was.split('\n').length, now.split('\n').length, 1)
64
- const kept = 1 - Math.max(st.add, st.del) / total
65
- if (kept >= score) { best = gone[i].path; score = kept }
66
- }
67
- return best
68
- }
69
-
70
- /** Whether commit `c` changed how many times `needle` appears in the changed files (a rename keeps the count). */
71
- async function changesCount (q, c, needle, prefixes, texts) {
72
- const parent = c.parents[0] || null
73
- let before = 0; let after = 0
74
- for (const ch of c.changes) {
75
- if (prefixes.length && !prefixes.some((p) => under(ch.path, p))) continue
76
- const was = await textOf(q, await entryAt(q, parent, ch.path), texts)
77
- const now = ch.op === 'put' ? await textOf(q, { blob: ch.blob }, texts) : null
78
- if (was) before += count(was, needle)
79
- if (now) after += count(now, needle)
80
- }
81
- return before !== after
82
- }
83
-
84
- /**
85
- * GET /log with follow (one file, through its renames) or search (-S): a walk over first parents.
86
- * Without either, the indexed log (service.js).
87
- */
88
- async function log (me, ref, opts = {}) {
89
- const { rev = 'main', paths = [], limit = 20, follow = false, search } = opts
90
- if (!follow && !search) return baseLog(me, ref, opts)
91
- const q = await quiltFor(me, ref)
92
- if (follow && (paths || []).length !== 1) throw new QuiltError(400, 'bad_request', 'log --follow takes one file')
93
- if (search != null && (!String(search).length || String(search).length > 500)) throw new QuiltError(400, 'bad_request', 'log -S takes a text of 1 to 500 characters')
94
- let range = String(rev || 'main')
95
- let stop = null
96
- if (range.includes('..')) {
97
- const [a, b] = range.split('..')
98
- stop = a ? await resolve(q.id, a) : null
99
- range = b || 'main'
100
- }
101
- let id = await resolve(q.id, range)
102
- let cur = follow ? cleanPath(paths[0]) : null
103
- const prefixes = follow ? [] : (paths || []).map(cleanPrefix)
104
- const max = Math.min(Number(limit) || 20, 1000)
105
- const texts = new Map()
106
- const out = []
107
- let walked = 0
108
- while (id && id !== stop && out.length < max && walked++ < MAX_WALK) {
109
- const c = await getCommit(q.id, id)
110
- if (follow) {
111
- const ch = c.changes.find((x) => x.path === cur)
112
- if (ch && (search == null || await changesCount(q.id, c, String(search), [cur], texts))) out.push({ ...commitView(c, { changes: false }), path: cur })
113
- if (ch && ch.op === 'put') { const from = await renamedFrom(q.id, c, ch, texts); if (from) cur = from }
114
- } else {
115
- const touched = !prefixes.length || c.changes.some((x) => prefixes.some((p) => under(x.path, p)))
116
- if (touched && await changesCount(q.id, c, String(search), prefixes, texts)) out.push(commitView(c, { changes: false }))
117
- }
118
- id = c.parents[0] || null
119
- }
120
- return { commits: out, more: !!id && id !== stop && out.length >= max, ...(follow ? { follow: cleanPath(paths[0]) } : {}) }
121
- }
122
-
123
- /** Every ancestor of `id` (all parents) with its distance, nearest first, up to MAX_WALK. */
124
- async function ancestors (q, id, { until } = {}) {
125
- const seen = new Map()
126
- if (!id) return seen
127
- let frontier = [id]
128
- seen.set(id, 0)
129
- for (let d = 1; frontier.length && seen.size < MAX_WALK; d++) {
130
- const next = []
131
- for (const x of frontier) {
132
- if (until && until(x)) continue
133
- for (const p of (await getCommit(q, x)).parents) if (!seen.has(p)) { seen.set(p, d); next.push(p) }
134
- }
135
- frontier = next
136
- }
137
- return seen
138
- }
139
-
140
- async function patchOf (q, id) { return id ? ((await store.getCommit(q, id))?.patch || null) : null }
141
-
142
- /** GET /describe?rev=: the nearest tag behind a revision, as <tag>-<commits past it>-<commit>. */
143
- async function describe (me, ref, rev = 'main') {
144
- const q = await quiltFor(me, ref)
145
- const id = await resolve(q.id, rev || 'main')
146
- if (!id) throw new QuiltError(404, 'not_found', `${rev || 'main'} has no commits yet`)
147
- const byCommit = new Map()
148
- for (const t of await store.tags(q.id)) {
149
- const had = byCommit.get(t.commit)
150
- if (!had || (t.seq || 0) > (had.seq || 0)) byCommit.set(t.commit, t)
151
- }
152
- const short = id.slice(0, 12)
153
- let hit = null
154
- if (byCommit.size) {
155
- const dist = await ancestors(q.id, id, { until: (x) => byCommit.has(x) })
156
- for (const [cid, d] of dist) if (byCommit.has(cid) && (!hit || d < hit.d)) hit = { d, tag: byCommit.get(cid).name }
157
- }
158
- if (!hit) return { commit: id, short, tag: null, distance: null, describe: short }
159
- return { commit: id, short, tag: hit.tag, distance: hit.d, describe: hit.d ? `${hit.tag}-${hit.d}-${short}` : hit.tag }
160
- }
161
-
162
- /** GET /merge-base?a=&b=: where two revisions last shared history (null if never). */
163
- async function mergeBaseOf (me, ref, a, b) {
164
- const q = await quiltFor(me, ref)
165
- if (!a || !b) throw new QuiltError(400, 'bad_request', 'merge-base takes two revisions (a and b)')
166
- const { base } = await commonBase(q.id, await resolve(q.id, a), await resolve(q.id, b))
167
- return { commit: base, short: base ? base.slice(0, 12) : null, patch: await patchOf(q.id, base) }
168
- }
169
-
170
- /** GET /grep: the lines matching a pattern in the files at a revision (sq:<id>:live too). */
171
- async function grep (me, ref, { pattern, rev = 'main', paths = [], ignore_case: ignoreCase = false, fixed = false, limit = 200 } = {}) {
172
- const q = await quiltFor(me, ref)
173
- const re = grepPattern(pattern, { fixed, ignoreCase })
174
- const { id, root } = await rootAt(q.id, rev || 'main')
175
- const prefixes = (paths && paths.length ? paths : ['']).map(cleanPrefix)
176
- const max = Math.min(Math.max(Number(limit) || 200, 1), 2000)
177
- const matches = []
178
- const files = []
179
- let bytes = 0; let more = false; let skipped = 0
180
- for (const e of await reader(q.id).list(root, prefixes)) {
181
- if (!e.blob || e.mode === 'gitlink' || e.mode === 'symlink') continue
182
- if ((e.size || 0) > MAX_GREP_FILE || bytes + (e.size || 0) > MAX_GREP_BYTES) { skipped++; continue }
183
- const buf = await readBlob(q.id, e.blob)
184
- bytes += buf.length
185
- if (!isText(buf)) continue
186
- const lines = buf.toString('utf8').split('\n')
187
- let hit = false
188
- for (let i = 0; i < lines.length; i++) {
189
- const text = lines[i].length > 4000 ? lines[i].slice(0, 4000) : lines[i]
190
- if (!re.test(text)) continue
191
- if (matches.length >= max) { more = true; break }
192
- hit = true
193
- matches.push({ path: e.path, line: i + 1, text: text.replace(/\r$/, '') })
194
- }
195
- if (hit) files.push(e.path)
196
- if (more) break
197
- }
198
- return { commit: id, matches, files, more, ...(skipped ? { skipped } : {}) }
199
- }
200
-
201
- /**
202
- * GET /v1/search: grep across every quilt the caller can read (or the ones named), at one
203
- * revision (main by default). A quilt without that revision is skipped; limit counts all matches.
204
- */
205
- async function search (me, { pattern, rev = 'main', quilts = [], paths = [], ignore_case: ignoreCase = false, fixed = false, limit = 200 } = {}) {
206
- grepPattern(pattern, { fixed, ignoreCase })
207
- const max = Math.min(Math.max(Number(limit) || 200, 1), 2000)
208
- const named = (quilts || []).filter(Boolean)
209
- const targets = named.length ? await Promise.all(named.map((ref) => quiltFor(me, ref))) : await store.quiltsFor(me.member_id)
210
- if (targets.length > MAX_SEARCH_QUILTS) throw new QuiltError(400, 'bad_request', `search reads at most ${MAX_SEARCH_QUILTS} quilts at once: name some with quilts`)
211
- const matches = []; const files = []; const searched = []
212
- let more = false; let skipped = 0
213
- for (const q of targets) {
214
- if (matches.length >= max) { more = true; break }
215
- let r
216
- try { r = await grep(me, q.slug, { pattern, rev, paths, ignore_case: ignoreCase, fixed, limit: max - matches.length }) } catch (e) {
217
- if (e instanceof QuiltError && e.status === 404) continue
218
- throw e
219
- }
220
- searched.push(q.slug)
221
- for (const m of r.matches) matches.push({ quilt: q.slug, ...m })
222
- for (const f of r.files) files.push(`${q.slug}:${f}`)
223
- skipped += r.skipped || 0
224
- if (r.more) { more = true; break }
225
- }
226
- return { rev: rev || 'main', searched, matches, files, more, ...(skipped ? { skipped } : {}) }
227
- }
228
-
229
- /**
230
- * POST /bisect { bad, good, marks }: the first-parent commits after good up to bad, narrowed by
231
- * marks ({ <commit>: good | bad | skip }). Answers the next commit to test, or the first bad one.
232
- */
233
- async function bisect (me, ref, { bad = 'main', good, marks = {} } = {}) {
234
- const q = await quiltFor(me, ref)
235
- if (!good) throw new QuiltError(400, 'bad_request', 'bisect needs a good revision (one without the problem)')
236
- const [b, g] = [await resolve(q.id, bad), await resolve(q.id, good)]
237
- if (!b || !g) throw new QuiltError(404, 'not_found', 'bisect needs two revisions with commits')
238
- const behindGood = await ancestors(q.id, g)
239
- const list = []
240
- let id = b
241
- while (id && !behindGood.has(id) && list.length < MAX_WALK) { list.push(id); id = (await getCommit(q.id, id)).parents[0] || null }
242
- if (!id || !list.length) throw new QuiltError(400, 'bad_request', `${good} is not an ancestor of ${bad}: give a good revision older than the bad one`)
243
- list.reverse() // oldest first; the last is bad
244
- const mark = new Map()
245
- for (const [k, v] of Object.entries(marks || {})) {
246
- if (!['good', 'bad', 'skip'].includes(v)) throw new QuiltError(400, 'bad_request', `a mark is good, bad or skip, not ${v}`)
247
- const full = list.find((x) => x.startsWith(String(k).replace(/^c:/, '')))
248
- if (full) mark.set(full, v)
249
- }
250
- let hi = list.length - 1
251
- for (let i = 0; i < list.length; i++) if (mark.get(list[i]) === 'bad') { hi = i; break }
252
- let lo = -1
253
- for (let i = hi - 1; i >= 0; i--) if (mark.get(list[i]) === 'good') { lo = i; break }
254
- const left = []
255
- for (let i = lo + 1; i < hi; i++) if (mark.get(list[i]) !== 'skip') left.push(i)
256
- const view = async (cid) => commitView(await getCommit(q.id, cid), { changes: false })
257
- if (!left.length) {
258
- const skipped = list.slice(lo + 1, hi)
259
- return { done: true, first_bad: await view(list[hi]), ...(skipped.length ? { maybe: await Promise.all(skipped.map(view)) } : {}), tested: mark.size }
260
- }
261
- const next = left[Math.floor((left.length - 1) / 2)]
262
- return { done: false, next: await view(list[next]), remaining: left.length, steps: Math.ceil(Math.log2(left.length + 1)) }
263
- }
264
-
265
- return { log, describe, mergeBaseOf, grep, search, bisect }
266
- }
package/src/quilts/ids.js DELETED
@@ -1,12 +0,0 @@
1
- // Ids shared by the quilt server and seam's client (the git import makes its squares' ids).
2
- import crypto from 'node:crypto'
3
-
4
- /** A UUIDv7: 48 bits of milliseconds, then random (README §3.3: time-sortable, never collides). */
5
- export function uuidv7 (ms = Date.now()) {
6
- const b = crypto.randomBytes(16)
7
- b.writeUIntBE(ms, 0, 6)
8
- b[6] = (b[6] & 0x0f) | 0x70
9
- b[8] = (b[8] & 0x3f) | 0x80
10
- const h = b.toString('hex')
11
- return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
12
- }
@@ -1,236 +0,0 @@
1
- // The remote driver of a git import, and the follow (plans/quilt-source-control/14, "Who runs
2
- // it and where" and "Importing a live repository"; 16 step 1). POST /v1/imports makes a new
3
- // quilt and imports a git remote into it from a mirror in scratch, with the same code as the
4
- // local driver (import-git.js). With follow: true the import then keeps following the remote's
5
- // main and release/* every `interval` seconds (default 600, at least 60), or at once on a poke
6
- // (POST /v1/imports/:id/poke, with the import's secret, so a git hook or a cron job can send it
7
- // without a member's key). While it follows, those lines are frozen with reason import_follow:
8
- // merges into them are refused with the import's id, because git is the authority. A rewritten
9
- // ref stops the follow (state failed, error 'ref rewritten'). One import per quilt, never
10
- // reopened (D19): the import's id is its quilt's id.
11
- import fs from 'node:fs'
12
- import os from 'node:os'
13
- import path from 'node:path'
14
- import crypto from 'node:crypto'
15
- import { execFile } from 'node:child_process'
16
- import { QuiltError } from './service.js'
17
- import { buildPack } from './pack.js'
18
- import { importGit } from './import-git.js'
19
-
20
- export const POKE_PREFIX = 'sip_'
21
- const hash = (s) => crypto.createHash('sha256').update(String(s)).digest('hex')
22
- const followed = (name) => name === 'main' || name.startsWith('release/')
23
- const LEASE = ' import' // a line lease no line can hold: one pass at a time across servers
24
-
25
- /** Who may read a remote: a URL a server may fetch (never a path on the server unless allowLocal). */
26
- export function remoteUrl (raw, { allowLocal = false } = {}) {
27
- const s = String(raw || '').trim()
28
- if (/^(https?|ssh|git):\/\/[^\s]+$/i.test(s) || /^[\w.-]+@[\w.-]+:[^\s]+$/.test(s)) return s
29
- if (allowLocal && (path.isAbsolute(s) || /^file:\/\//.test(s))) return s
30
- throw new QuiltError(400, 'bad_request', 'url: a git remote (https://, ssh:// or user@host:path)')
31
- }
32
-
33
- export function run (args, { env = {}, cwd } = {}) {
34
- return new Promise((resolve, reject) => {
35
- execFile('git', args, { cwd, env: { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_TERMINAL_PROMPT: '0', ...env }, maxBuffer: 64 << 20 }, (err, stdout, stderr) => {
36
- if (err) return reject(new Error(String(stderr || err.message).trim().split('\n').pop()))
37
- resolve(stdout)
38
- })
39
- })
40
- }
41
-
42
- /** The import's credential as git config: `user:token`, or a bare token as x-access-token's. */
43
- export function authArgs (secret) {
44
- if (!secret) return []
45
- const pair = secret.includes(':') ? secret : `x-access-token:${secret}`
46
- return ['-c', `http.extraHeader=Authorization: Basic ${Buffer.from(pair).toString('base64')}`]
47
- }
48
-
49
- /** importGit's client, in this process: the same operations the HTTP routes call. */
50
- export function localClient (api, me) {
51
- return {
52
- quilt: (q) => api.quiltInfo(me, q),
53
- createQuilt: (b) => api.createQuilt(me, b),
54
- importKnown: (q, shas) => api.importKnown(me, q, { shas }),
55
- lines: async (q) => (await api.lines(me, q)).lines,
56
- tags: async (q) => (await api.tags(me, q)).tags,
57
- createTag: (q, b) => api.createTag(me, q, b),
58
- tree: (q, rev, paths) => api.tree(me, q, rev, { paths: paths || [''] }),
59
- exportPatches: (q, o) => api.exportPatches(me, q, o),
60
- blobs: (q, ids) => api.blobs(me, q, ids),
61
- importCommits: (q, items, lines = []) => api.importBatch(me, q, JSON.parse(JSON.stringify({ items, lines }))),
62
- async upload (q, objs) {
63
- const missing = new Set()
64
- for (let i = 0; i < objs.length; i += 10000) for (const id of (await api.have(me, q, objs.slice(i, i + 10000).map((o) => o.id))).missing) missing.add(id)
65
- let batch = []; let size = 0
66
- const flush = async () => { if (batch.length) await api.uploadPack(me, q, buildPack(batch).bytes); batch = []; size = 0 }
67
- for (const o of objs.filter((x) => missing.has(x.id))) {
68
- if (size + o.bytes.length > 48 * 1024 * 1024) await flush()
69
- batch.push(o); size += o.bytes.length
70
- }
71
- await flush()
72
- }
73
- }
74
- }
75
-
76
- export function importFollowOps (svc, api, { scratch = path.join(os.tmpdir(), 'seam-imports'), allowLocal = false, secret = (name) => process.env[`SEAM_SECRET_${String(name).toUpperCase().replace(/[^A-Z0-9]/g, '_')}`] || null, holder = `follower:${process.pid}:${crypto.randomBytes(4).toString('hex')}`, tickMs = 30_000 } = {}) {
77
- const { store, now, quiltFor, fromStore } = svc.internals
78
- const running = new Map() // quilt id → the pass in flight
79
- const lastPass = new Map() // quilt id → when its last pass started, on this server
80
- let timer = null
81
-
82
- const view = (rec) => { const { poke_hash: _, ...rest } = rec; return { ...rest, quilt_id: rec.id } }
83
- const save = async (qid, rec, opts = {}) => { try { return await store.setImport({ quilt_id: qid, import: rec, actor: rec.by.member_id, at: now(), ...opts }) } catch (err) { throw fromStore(err) } }
84
-
85
- async function record (qid) {
86
- const q = await store.getQuilt(qid)
87
- return q?.settings?.import || null
88
- }
89
-
90
- /** One follow pass: fetch the remote into the mirror, import what is new, freeze what is followed. */
91
- async function pass (qid) {
92
- const rec = await record(qid)
93
- if (!rec || !['reading', 'following'].includes(rec.state)) return
94
- const lease = await store.takeLease({ kind: 'line', quilt_id: qid, line: LEASE, holder, ttl_ms: 30 * 60_000, at: now() })
95
- if (!lease.held) return
96
- lastPass.set(qid, now())
97
- const was = rec.state
98
- try {
99
- const mirror = path.join(scratch, qid)
100
- const auth = authArgs(rec.credential ? await secret(rec.credential, { quilt_id: qid }) : null)
101
- const env = { GIT_ALLOW_PROTOCOL: allowLocal ? 'https:http:ssh:git:file' : 'https:http:ssh:git' }
102
- if (!fs.existsSync(path.join(mirror, 'HEAD'))) {
103
- fs.mkdirSync(scratch, { recursive: true })
104
- fs.rmSync(mirror, { recursive: true, force: true })
105
- await run([...auth, 'clone', '--quiet', '--mirror', rec.source.url, mirror], { env })
106
- } else {
107
- await run([...auth, '-C', mirror, 'fetch', '--quiet', '--prune', 'origin'], { env })
108
- }
109
- const r = await importGit(localClient(api, rec.by), { repo: mirror, slug: qid, follow: true })
110
- const lines = []
111
- for (const l of await store.lines(qid)) {
112
- if (!followed(l.name)) continue
113
- lines.push({ line: l.name, patches: l.patch_count, tip_sha: l.tip ? await store.gitShaOf(qid, l.tip) : null })
114
- }
115
- const next = { ...rec, lines, passes: (rec.passes || 0) + 1, last_pass_at: now(), commits: (rec.commits || 0) + r.commits }
116
- delete next.error
117
- if (r.rewritten.length) Object.assign(next, { state: 'failed', error: 'ref rewritten', rewritten: r.rewritten, finished_at: now() })
118
- else if (!rec.follow) Object.assign(next, { state: 'done', finished_at: now() })
119
- else next.state = 'following'
120
- // While git leads, every followed line stays frozen (a stopped follow too: nobody merges
121
- // over git's history until the owner decides).
122
- await save(qid, next, { freeze: rec.follow ? true : null, lines: lines.map((l) => l.line), expect_state: was })
123
- } catch (err) {
124
- // A first pass that fails ends the import; a later one keeps following and says why.
125
- const cur = await record(qid)
126
- if (cur && cur.state === was) await save(qid, was === 'reading' ? { ...cur, state: 'failed', error: err.message, finished_at: now() } : { ...cur, error: err.message }, { expect_state: was }).catch(() => {})
127
- } finally {
128
- await store.giveLease({ kind: 'line', quilt_id: qid, line: LEASE, holder }).catch(() => {})
129
- }
130
- }
131
-
132
- /** Runs a pass now, or joins the one in flight. */
133
- function kick (qid) {
134
- if (!running.has(qid)) running.set(qid, pass(qid).catch(() => {}).finally(() => running.delete(qid)))
135
- return running.get(qid)
136
- }
137
-
138
- /** POST /v1/imports { url, slug, follow?, interval?, credential? } → the import, and its poke secret (shown once). */
139
- async function startImport (me, { url, slug, follow = false, interval = 600, credential = null } = {}) {
140
- const source = remoteUrl(url, { allowLocal })
141
- const s = String(slug || '').toLowerCase()
142
- let existing = null
143
- try { existing = await quiltFor(me, s) } catch (err) { if (err.status !== 404) throw err }
144
- if (existing) throw new QuiltError(409, existing.settings?.import ? 'import_closed' : 'slug_taken', existing.settings?.import ? `${s} already has its import (one import per quilt, never reopened)` : `you already have a quilt called ${s}`)
145
- if (credential != null && !/^[A-Za-z0-9_.-]{1,100}$/.test(String(credential))) throw new QuiltError(400, 'bad_request', 'credential: the name of a secret, not the secret')
146
- const q = await api.createQuilt(me, { slug: s, name: s, import: true })
147
- const poke = POKE_PREFIX + crypto.randomBytes(24).toString('base64url')
148
- const rec = { id: q.id, source: { url: source }, ...(credential ? { credential: String(credential) } : {}), follow: !!follow, interval_s: Math.max(60, Math.floor(Number(interval) || 600)), state: 'reading', lines: [], passes: 0, commits: 0, poke_hash: hash(poke), by: { member_id: me.member_id, kind: me.kind || 'person', name: me.name || null }, started_at: now() }
149
- // main is frozen before anything is imported, so nothing merges ahead of git.
150
- await save(q.id, rec, { freeze: rec.follow ? true : null, lines: ['main'], expect_state: null })
151
- kick(q.id)
152
- return { ...view(rec), poke_secret: poke }
153
- }
154
-
155
- async function importStatus (me, id) {
156
- const q = await quiltFor(me, id, 'read')
157
- const rec = q.settings?.import
158
- if (!rec) throw new QuiltError(404, 'not_found', 'this quilt has no import')
159
- return view(rec)
160
- }
161
-
162
- /** POST /v1/imports/:id/poke: a member who can write, or anyone with the import's secret. */
163
- async function pokeImport (me, id, { secret: given } = {}) {
164
- let rec
165
- if (given) {
166
- const q = /^[0-9a-f-]{36}$/i.test(String(id)) ? await store.getQuilt(String(id)) : null
167
- rec = q?.settings?.import
168
- if (!rec || !rec.poke_hash || !crypto.timingSafeEqual(Buffer.from(rec.poke_hash), Buffer.from(hash(given)))) throw new QuiltError(401, 'bad_token', 'this poke secret is not the import\'s')
169
- } else {
170
- const q = await quiltFor(me, id, 'write')
171
- rec = q.settings?.import
172
- if (!rec) throw new QuiltError(404, 'not_found', 'this quilt has no import')
173
- }
174
- if (rec.state !== 'following') throw new QuiltError(409, 'not_following', `the import is ${rec.state}, not following`)
175
- kick(rec.id)
176
- return { ok: true, id: rec.id, queued: true }
177
- }
178
-
179
- /**
180
- * POST /v1/imports/:id/cutover (admin; 14 step 3, 16 step 3): a last follow pass, the remote's
181
- * tips checked against the imported tips, then in one go: the follow ended for good (state
182
- * done, closed), the followed lines unfrozen, and the export of main to the source turned on.
183
- * Refuses (remote_moved, or conflict when a line moved in Seam) and leaves the follow as it was.
184
- */
185
- async function cutover (me, id, { credential } = {}) {
186
- const q = await quiltFor(me, id, 'admin')
187
- if (!q.settings?.import) throw new QuiltError(404, 'not_found', 'this quilt has no import')
188
- if (q.settings.import.state !== 'following') throw new QuiltError(409, 'not_following', `the import is ${q.settings.import.state}, not following`)
189
- await (running.get(q.id) || Promise.resolve())
190
- await kick(q.id)
191
- const rec = await record(q.id)
192
- if (rec.state !== 'following') throw new QuiltError(409, 'not_following', `the last follow pass ended the import: ${rec.error || rec.state}`)
193
- const auth = authArgs(rec.credential ? await secret(rec.credential, { quilt_id: q.id }) : null)
194
- const env = { GIT_ALLOW_PROTOCOL: allowLocal ? 'https:http:ssh:git:file' : 'https:http:ssh:git' }
195
- const remote = new Map()
196
- for (const row of (await run([...auth, 'ls-remote', rec.source.url, 'HEAD', 'refs/heads/release/*'], { env })).split('\n').filter(Boolean)) {
197
- const [sha, ref] = row.split('\t')
198
- remote.set(ref === 'HEAD' ? 'main' : ref.replace(/^refs\/heads\//, ''), sha)
199
- }
200
- const have = new Map(rec.lines.map((l) => [l.line, l.tip_sha]))
201
- for (const [line, sha] of remote) if (have.get(line) !== sha) throw new QuiltError(409, 'remote_moved', `the remote's ${line} is ${sha.slice(0, 12)}, the import has ${have.has(line) ? String(have.get(line)).slice(0, 12) : 'no such line'}: stop pushes to the remote (branch protection), then cut over again`, { line, sha })
202
- const tips = {}
203
- for (const l of await store.lines(q.id)) if (followed(l.name)) tips[l.name] = l.tip
204
- const done = { ...rec, state: 'done', closed: true, finished_at: now() }
205
- delete done.error
206
- await save(q.id, done, { freeze: false, lines: Object.keys(tips), expect_state: 'following', expect_tips: tips })
207
- let exp
208
- try {
209
- exp = await api.setExport(me, q.id, { target: rec.source.url, credential: credential || rec.credential || null, lines: ['main'], releases: false })
210
- } catch (err) {
211
- // The export must be on before anything merges in Seam: put the follow back as it was.
212
- await save(q.id, rec, { freeze: true, lines: Object.keys(tips), expect_state: 'done' }).catch(() => {})
213
- throw err
214
- }
215
- return { ok: true, import: view(done), export: exp }
216
- }
217
-
218
- const follower = {
219
- /** Every tick, a pass for each following import whose interval is up (on this server). */
220
- start () {
221
- if (timer) return
222
- const tick = async () => {
223
- let list = []
224
- try { list = await store.importsFollowing() } catch { return }
225
- for (const { quilt_id: qid, import: rec } of list) if (now() - (lastPass.get(qid) || 0) >= rec.interval_s * 1000) kick(qid)
226
- api.exportTick?.().catch(() => {})
227
- }
228
- timer = setInterval(tick, tickMs)
229
- timer.unref?.()
230
- tick()
231
- },
232
- stop () { if (timer) clearInterval(timer); timer = null }
233
- }
234
-
235
- return { startImport, importStatus, pokeImport, cutover, importIdle: (id) => running.get(id) || Promise.resolve(), follower, importPass: kick }
236
- }