heyquilt 0.3.121 → 0.3.122
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 +19 -0
- package/bin/quilt.js +23 -2
- package/package.json +2 -1
- package/src/api/memory-store.js +5 -1
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +36 -0
- package/src/api/server.js +37 -9
- package/src/api/supabase-store.js +10 -0
- package/src/connection.js +4 -1
- package/src/control.js +12 -0
- package/src/history.js +15 -1
- package/src/mcp.js +12 -1
- package/src/quilts/accounts.js +136 -0
- package/src/quilts/affected.js +132 -0
- package/src/quilts/checks.js +308 -0
- package/src/quilts/cli.js +97 -10
- package/src/quilts/client.js +9 -0
- package/src/quilts/describe.js +31 -1
- package/src/quilts/events.js +13 -2
- package/src/quilts/export-remote.js +156 -0
- package/src/quilts/import-follow.js +236 -0
- package/src/quilts/import-git.js +6 -3
- package/src/quilts/import.js +67 -5
- package/src/quilts/index.js +28 -6
- package/src/quilts/keys.js +186 -0
- package/src/quilts/merge.js +16 -1
- package/src/quilts/oauth.js +202 -0
- package/src/quilts/reviews.js +18 -5
- package/src/quilts/routes.js +61 -1
- package/src/quilts/runner.js +95 -0
- package/src/quilts/server.js +144 -0
- package/src/quilts/service.js +41 -1
- package/src/quilts/setup.js +9 -2
- package/src/quilts/store-memory.js +164 -3
- package/src/quilts/store-rpc.js +12 -1
- package/src/quilts/tools.js +39 -11
- package/src/quilts/window.js +2 -0
- package/src/relay-mcp.js +33 -3
- package/src/relay-prune.js +4 -1
- package/src/server.js +317 -14
- package/src/session-follow.js +81 -0
- package/src/session-layout3.js +60 -0
- package/src/session-migrate.js +119 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +71 -0
- package/src/session.js +74 -2
- package/src/source-live/live-block.js +4 -3
- package/src/source-live/live-square.js +1 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Quilt's side of Seam: a session with no git remote becomes a quilt
|
|
2
|
+
// (plans/quilt-source-control/16, "How a session becomes a quilt", "Without history").
|
|
3
|
+
// It calls Seam through a client with the quiltClient shape (src/quilts/client.js), acting as
|
|
4
|
+
// the session's owner, who becomes the quilt's admin. Seam (src/quilts/) never imports this.
|
|
5
|
+
//
|
|
6
|
+
// 1. a quilt named after the room (slug from the room, `-2`.. when taken);
|
|
7
|
+
// 2. patch 1 on main: the default branch document's files, sealed in a square of the owner's
|
|
8
|
+
// and merged, message "Session <room> as of <date>";
|
|
9
|
+
// 3. one square per other branch document, based at patch 1, whose live changes are its
|
|
10
|
+
// difference from patch 1 (so it shows as uncommitted work).
|
|
11
|
+
//
|
|
12
|
+
// 4. each open merge record (state open or editing) as a conflict term in its square's first
|
|
13
|
+
// commit (16's merges row): `ours` the opener's text, on the migrated square; `theirs` the
|
|
14
|
+
// session's text, naming patch 1's square and commit; the record's base as the remove. A
|
|
15
|
+
// default branch's open merges get a square of their own ('(default)'), as main is patch 1.
|
|
16
|
+
// Binary records, and records whose text stayed on the opener's disk, have nothing to put
|
|
17
|
+
// in a term: they are listed in `merges_left`.
|
|
18
|
+
//
|
|
19
|
+
// Files kept in storage (large files) have no text on the relay; they are listed in
|
|
20
|
+
// `left_out` and stay in the session's storage.
|
|
21
|
+
|
|
22
|
+
export const slugFor = (room) => String(room || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 56) || 'session'
|
|
23
|
+
|
|
24
|
+
const b64 = (text) => Buffer.from(String(text)).toString('base64')
|
|
25
|
+
const put = (path, text) => ({ path, op: 'put', content: b64(text) })
|
|
26
|
+
|
|
27
|
+
/** The changes that turn `base` ({ path: text }) into `files`. */
|
|
28
|
+
export function changesFrom (base, files) {
|
|
29
|
+
const out = []
|
|
30
|
+
for (const [p, t] of Object.entries(files || {})) if (!base || base[p] !== t) out.push(put(p, t))
|
|
31
|
+
for (const p of Object.keys(base || {})) if (!(p in (files || {}))) out.push({ path: p, op: 'delete' })
|
|
32
|
+
return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const taken = (e) => e && (e.status === 409 || e.code === 'conflict' || e.code === 'slug_taken' || /taken|exists|duplicate/i.test(String(e.message)))
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Makes the quilt. `defaultFiles`: { path: text } of the default branch document; `branches`:
|
|
39
|
+
* [{ key, files: { path: text } }] for the others; `leftOut`: paths with no text. Returns
|
|
40
|
+
* { quilt_id, slug, patch, squares: [{ from, id }], left_out }. A failure after the quilt was
|
|
41
|
+
* made throws with `quilt_id` on the error, so the caller can name it.
|
|
42
|
+
*/
|
|
43
|
+
export const DEFAULT_MERGES = '(default)'
|
|
44
|
+
const OPEN = new Set(['open', 'editing'])
|
|
45
|
+
|
|
46
|
+
/** The open merges of one branch → { terms: conflict changes, left: records with no text to carry }. */
|
|
47
|
+
export function termsFor (merges, files, { square, patchSquare, patch }) {
|
|
48
|
+
const terms = []; const left = []; const seen = new Set()
|
|
49
|
+
for (const m of merges || []) {
|
|
50
|
+
if (!m || !OPEN.has(m.state) || seen.has(m.path)) continue
|
|
51
|
+
if (m.binary || m.local || (m.ours == null && !m.oursDeleted)) { left.push(m); continue }
|
|
52
|
+
seen.add(m.path)
|
|
53
|
+
const now = files && Object.prototype.hasOwnProperty.call(files, m.path) ? files[m.path] : null
|
|
54
|
+
if (now == null && m.oursDeleted) { left.push(m); continue } // both sides gone: nothing conflicts
|
|
55
|
+
terms.push({
|
|
56
|
+
path: m.path,
|
|
57
|
+
op: 'conflict',
|
|
58
|
+
reason: 'import',
|
|
59
|
+
base: m.base != null ? { content: b64(m.base) } : null,
|
|
60
|
+
ours: m.oursDeleted ? null : { content: b64(m.ours), from_square: square, ...(m.by ? { by: String(m.by) } : {}) },
|
|
61
|
+
theirs: now == null ? null : { content: b64(now), ...(patchSquare ? { from_square: patchSquare } : {}), ...(patch ? { from_commit: patch.commit } : {}) }
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
return { terms, left }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function makeQuiltWithoutHistory ({ seam, room, defaultFiles = {}, defaultMerges = [], branches = [], leftOut = [], date = new Date() }) {
|
|
68
|
+
const base = slugFor(room)
|
|
69
|
+
let q = null
|
|
70
|
+
for (let n = 1; !q && n <= 20; n++) {
|
|
71
|
+
const slug = n === 1 ? base : `${base}-${n}`
|
|
72
|
+
try { q = await seam.createQuilt({ slug, name: room }) } catch (e) { if (!taken(e) || n === 20) throw e }
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
let patch = null
|
|
76
|
+
let patchSquare = null
|
|
77
|
+
const first = changesFrom(null, defaultFiles)
|
|
78
|
+
if (first.length) {
|
|
79
|
+
const day = new Date(date).toISOString().slice(0, 10)
|
|
80
|
+
const s = await seam.openSquare(q.id, { title: `Session ${room}` })
|
|
81
|
+
patchSquare = s.id
|
|
82
|
+
await seam.commit(q.id, s.id, { expect_head: null, message: `Session ${room} as of ${day}`, changes: first })
|
|
83
|
+
const m = await seam.merge(q.id, 'main', { square: s.id })
|
|
84
|
+
if (!m || !m.ok || !m.merged) throw Object.assign(new Error(`Seam did not merge patch 1: ${(m && ((m.refused && m.refused.detail) || m.error || m.code)) || "no answer"}`), { code: m && m.code })
|
|
85
|
+
patch = { no: m.merged.patch_no, commit: m.merged.commit }
|
|
86
|
+
}
|
|
87
|
+
const squares = []
|
|
88
|
+
const terms = []
|
|
89
|
+
const mergesLeft = []
|
|
90
|
+
// Open merges as terms in the square's first commit; the rest of its work stays live.
|
|
91
|
+
const carry = async (from, sq, merges, files) => {
|
|
92
|
+
const t = termsFor(merges, files, { square: sq, patchSquare, patch })
|
|
93
|
+
for (const m of t.left) mergesLeft.push({ from, path: m.path, id: m.id })
|
|
94
|
+
if (!t.terms.length) return new Set()
|
|
95
|
+
await seam.commit(q.id, sq, { expect_head: null, message: `Open merges from session ${room}: ${t.terms.map((x) => x.path).join(', ')}`.slice(0, 2000), changes: t.terms })
|
|
96
|
+
for (const x of t.terms) terms.push({ from, path: x.path, square: sq })
|
|
97
|
+
return new Set(t.terms.map((x) => x.path))
|
|
98
|
+
}
|
|
99
|
+
const defaultLeft = termsFor(defaultMerges, defaultFiles, {})
|
|
100
|
+
if (!defaultLeft.terms.length) for (const m of defaultLeft.left) mergesLeft.push({ from: DEFAULT_MERGES, path: m.path, id: m.id })
|
|
101
|
+
else {
|
|
102
|
+
const s = await seam.openSquare(q.id, { title: `Open merges from session ${room}` })
|
|
103
|
+
const held = await carry(DEFAULT_MERGES, s.id, defaultMerges, defaultFiles)
|
|
104
|
+
if (held.size) squares.push({ from: DEFAULT_MERGES, id: s.id })
|
|
105
|
+
}
|
|
106
|
+
for (const b of branches) {
|
|
107
|
+
const s = await seam.openSquare(q.id, { title: String(b.key).slice(0, 200) })
|
|
108
|
+
const held = await carry(b.key, s.id, b.merges, b.files)
|
|
109
|
+
const changes = changesFrom(defaultFiles, b.files).filter((c) => !held.has(c.path))
|
|
110
|
+
if (changes.length) await seam.setLive(q.id, s.id, { changes })
|
|
111
|
+
squares.push({ from: b.key, id: s.id })
|
|
112
|
+
}
|
|
113
|
+
return { quilt_id: q.id, slug: q.slug, patch, squares, terms, merges_left: mergesLeft, left_out: [...leftOut].sort() }
|
|
114
|
+
} catch (e) {
|
|
115
|
+
e.quilt_id = q.id
|
|
116
|
+
e.message = `${e.message} (quilt ${q.slug} was made but the session was not moved into it)`
|
|
117
|
+
throw e
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Quilt's side of Seam: what making a session a quilt would do, without doing it
|
|
2
|
+
// (plans/quilt-source-control/16, "How a session becomes a quilt", "Dry run and check").
|
|
3
|
+
// Pure: it reads a snapshot of the room (Room.quiltSnapshot in server.js) and returns the
|
|
4
|
+
// report the owner reads before the real migration. Seam (src/quilts/) never imports this.
|
|
5
|
+
//
|
|
6
|
+
// Snapshot:
|
|
7
|
+
// { room, owner, defaultKey, hasRemote,
|
|
8
|
+
// branches: [{ key, by, files: { path: { hash, bytes } }, history: [entry], merges: [{ id, state }] }],
|
|
9
|
+
// claims: [{ pattern, branch, by, byId, queue }], tasks: [{ id, pr }], commitRequests: n }
|
|
10
|
+
// Options:
|
|
11
|
+
// tip: { path: hash } the imported main's tree, when the room has a remote and its import exists.
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
|
|
14
|
+
export const fileHash = (text) => createHash('sha256').update(String(text)).digest('hex')
|
|
15
|
+
|
|
16
|
+
const sumBytes = (files) => Object.values(files || {}).reduce((n, f) => n + (Number(f && f.bytes) || 0), 0)
|
|
17
|
+
|
|
18
|
+
/** The paths whose content differs between a branch document's files and a tree, both `{ path: hash }`-like. */
|
|
19
|
+
export function differingPaths (files, tree) {
|
|
20
|
+
const out = []
|
|
21
|
+
for (const [p, f] of Object.entries(files || {})) if (tree[p] !== (f && f.hash)) out.push(p)
|
|
22
|
+
for (const p of Object.keys(tree || {})) if (!(p in (files || {}))) out.push(p)
|
|
23
|
+
return out.sort()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The dry run's report. `mode` is `with_history` for a room with a git remote (the import and
|
|
28
|
+
* its follow come first; `needs_import` until a `tip` is given), else `without_history` (the
|
|
29
|
+
* default branch document is sealed as patch 1).
|
|
30
|
+
*/
|
|
31
|
+
export function planSessionQuilt (snap, { tip = null } = {}) {
|
|
32
|
+
const withHistory = !!snap.hasRemote
|
|
33
|
+
const defaultKey = snap.defaultKey
|
|
34
|
+
const owner = snap.owner || ''
|
|
35
|
+
const branches = (snap.branches || []).filter((b) => b && typeof b.key === 'string')
|
|
36
|
+
const def = branches.find((b) => b.key === defaultKey) || null
|
|
37
|
+
const squares = []
|
|
38
|
+
const report = {
|
|
39
|
+
room: snap.room,
|
|
40
|
+
mode: withHistory ? 'with_history' : 'without_history',
|
|
41
|
+
needs_import: withHistory && !tip,
|
|
42
|
+
first_patch: null,
|
|
43
|
+
squares,
|
|
44
|
+
claims: { moved: 0, opened_for: [], without_square: [] },
|
|
45
|
+
tasks: { matched: [], unmatched: [] },
|
|
46
|
+
terms: 0,
|
|
47
|
+
history: { kept: 0, dropped: 0 },
|
|
48
|
+
commit_requests: Number(snap.commitRequests) || 0,
|
|
49
|
+
bytes: 0
|
|
50
|
+
}
|
|
51
|
+
if (def && !withHistory) {
|
|
52
|
+
report.first_patch = { from: def.key, message: `Session ${snap.room} as of <date>`, files: Object.keys(def.files || {}).length, bytes: sumBytes(def.files) }
|
|
53
|
+
}
|
|
54
|
+
if (def && withHistory && tip) {
|
|
55
|
+
const paths = differingPaths(def.files, tip)
|
|
56
|
+
if (paths.length) {
|
|
57
|
+
squares.push({ from: def.key, title: `Uncommitted work from ${snap.room}`, base: 'import:main', holder: owner, adoption: true, files: paths.length, bytes: paths.reduce((n, p) => n + (Number(def.files[p] && def.files[p].bytes) || 0), 0) })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const squareOf = new Map()
|
|
61
|
+
for (const b of branches) {
|
|
62
|
+
if (b.key === defaultKey) continue
|
|
63
|
+
const at = /^@([0-9a-f]{7,64})$/.exec(b.key)
|
|
64
|
+
const base = withHistory ? (at ? `commit:${at[1]}` : `import:${b.key}`) : 'patch:1'
|
|
65
|
+
const sq = { from: b.key, title: b.key, base, holder: b.by || owner, adoption: false, files: Object.keys(b.files || {}).length, bytes: sumBytes(b.files) }
|
|
66
|
+
squares.push(sq)
|
|
67
|
+
squareOf.set(b.key, sq)
|
|
68
|
+
}
|
|
69
|
+
const adoption = squares.find((s) => s.adoption) || null
|
|
70
|
+
const opened = new Set()
|
|
71
|
+
for (const c of snap.claims || []) {
|
|
72
|
+
const branch = c.branch || defaultKey
|
|
73
|
+
const holder = c.byId || c.by || ''
|
|
74
|
+
if (branch === defaultKey) {
|
|
75
|
+
if (adoption) { report.claims.moved++; continue }
|
|
76
|
+
if (!def) { report.claims.without_square.push({ pattern: c.pattern, branch, holder }); continue }
|
|
77
|
+
// No square holds the default branch's work: one is opened for the claim's holder.
|
|
78
|
+
if (!opened.has(holder)) { opened.add(holder); report.claims.opened_for.push(holder) }
|
|
79
|
+
report.claims.moved++
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (squareOf.has(branch)) report.claims.moved++
|
|
83
|
+
else report.claims.without_square.push({ pattern: c.pattern, branch, holder })
|
|
84
|
+
}
|
|
85
|
+
for (const t of snap.tasks || []) {
|
|
86
|
+
if (!t || !t.pr) continue
|
|
87
|
+
const b = t.pr.branch
|
|
88
|
+
if (b && squareOf.has(b)) report.tasks.matched.push({ id: t.id, square_from: b })
|
|
89
|
+
else report.tasks.unmatched.push({ id: t.id, pr: t.pr.number || null })
|
|
90
|
+
}
|
|
91
|
+
for (const b of branches) {
|
|
92
|
+
report.terms += (b.merges || []).filter((m) => m && (m.state === 'open' || m.state === 'editing')).length
|
|
93
|
+
for (const h of b.history || []) {
|
|
94
|
+
if (withHistory && h && (h.pulled || h.seed)) report.history.dropped++
|
|
95
|
+
else report.history.kept++
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
report.bytes = squares.reduce((n, s) => n + s.bytes, 0) + (report.first_patch ? report.first_patch.bytes : 0)
|
|
99
|
+
report.problems = [
|
|
100
|
+
...report.claims.without_square.map((c) => `claim on ${c.pattern} (${c.branch}) has no square`),
|
|
101
|
+
...report.tasks.unmatched.map((t) => `task ${t.id}'s pull request${t.pr ? ` #${t.pr}` : ''} has no square; it becomes a task comment`)
|
|
102
|
+
]
|
|
103
|
+
return report
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const MIGRATE_SESSION_DESCRIPTION =
|
|
107
|
+
'What making this session a quilt (Quilt\'s own source control, Seam) would do, read only: the first patch or the import, ' +
|
|
108
|
+
'which branches become squares, how claims and tasks with pull requests move, and anything that would not carry over. ' +
|
|
109
|
+
'Only the session\'s owner (or, in a session without one, anyone who may change files) can ask. Nothing changes.'
|
|
110
|
+
|
|
111
|
+
/** Why `a` (the asker's access) may not see the dry run, or null when they may. */
|
|
112
|
+
export function quiltPlanRefusal ({ controlled, access }) {
|
|
113
|
+
if (controlled ? !(access && access.owner) : !!(access && access.role === 'viewer')) return 'only the session\'s owner can see what making it a quilt would do'
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const kb = (n) => n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`
|
|
118
|
+
|
|
119
|
+
/** The dry run's report as plain text, for an agent or a person. */
|
|
120
|
+
export function describeQuiltPlan (p) {
|
|
121
|
+
const out = [`Making session ${p.room} a quilt (dry run, nothing changed):`]
|
|
122
|
+
if (p.mode === 'with_history') {
|
|
123
|
+
out.push(p.needs_import
|
|
124
|
+
? '- The session has a git remote: its history comes in by an import that follows the remote first (seam import), then the session moves.'
|
|
125
|
+
: '- History comes from the import of the session\'s git remote.')
|
|
126
|
+
} else if (p.first_patch) {
|
|
127
|
+
out.push(`- Patch 1 is the ${p.first_patch.from} branch as it is now: ${p.first_patch.files} file${p.first_patch.files === 1 ? '' : 's'}, ${kb(p.first_patch.bytes)}.`)
|
|
128
|
+
} else {
|
|
129
|
+
out.push('- The session has no default branch document: patch 1 starts empty.')
|
|
130
|
+
}
|
|
131
|
+
if (p.squares.length) {
|
|
132
|
+
out.push(`- ${p.squares.length} square${p.squares.length === 1 ? '' : 's'}:`)
|
|
133
|
+
for (const s of p.squares) out.push(` - ${s.title} from ${s.from}, on ${s.base}, held by ${s.holder || 'nobody'}: ${s.files} file${s.files === 1 ? '' : 's'}`)
|
|
134
|
+
} else out.push('- No squares: there is no other branch with work on it.')
|
|
135
|
+
out.push(`- Claims: ${p.claims.moved} move to their squares${p.claims.opened_for.length ? ` (a square is opened for ${p.claims.opened_for.join(', ')})` : ''}.`)
|
|
136
|
+
if (p.tasks.matched.length) out.push(`- ${p.tasks.matched.length} task${p.tasks.matched.length === 1 ? '' : 's'} with a pull request link${p.tasks.matched.length === 1 ? 's' : ''} to a square.`)
|
|
137
|
+
if (p.terms) out.push(`- ${p.terms} open merge${p.terms === 1 ? '' : 's'} become${p.terms === 1 ? 's' : ''} terms on their squares.`)
|
|
138
|
+
out.push(`- History: ${p.history.kept} entr${p.history.kept === 1 ? 'y' : 'ies'} kept${p.history.dropped ? `, ${p.history.dropped} dropped (they are in the import)` : ''}.`)
|
|
139
|
+
if (p.commit_requests) out.push(`- ${p.commit_requests} commit request${p.commit_requests === 1 ? '' : 's'} must be done or withdrawn first.`)
|
|
140
|
+
if (p.problems && p.problems.length) {
|
|
141
|
+
out.push('Would not carry over:')
|
|
142
|
+
for (const x of p.problems) out.push(`- ${x}`)
|
|
143
|
+
}
|
|
144
|
+
return out.join('\n')
|
|
145
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Quilt's side of Seam for agents (plans/quilt-source-control/12 and 16): in a session that is a
|
|
2
|
+
// quilt, an agent works through Seam (squares, commits, merges), never GitHub. The same for every
|
|
3
|
+
// AI tool: the shared MCP tools refuse their GitHub path with the Seam tool to use instead, and the
|
|
4
|
+
// relay gives no GitHub token to an agent's git there. Seam (src/quilts/) never imports this.
|
|
5
|
+
//
|
|
6
|
+
// A session is a quilt when the relay is on Seam (QUILT_SOURCE_URL) and its room names its quilt
|
|
7
|
+
// (meta.quilt_id, set by the migration with layout 3 and cleared when a failed check reverts it).
|
|
8
|
+
// Every other session keeps its GitHub path exactly as it is.
|
|
9
|
+
|
|
10
|
+
/** The quilt a room's session is, or null while it is a GitHub-backed (or plain) session. */
|
|
11
|
+
export function quiltOf (meta, env = process.env) {
|
|
12
|
+
if (!env || !env.QUILT_SOURCE_URL) return null
|
|
13
|
+
if (!meta) return null
|
|
14
|
+
return typeof meta.quilt_id === 'string' && meta.quilt_id ? meta.quilt_id : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Each GitHub tool, and the Seam tool an agent uses for it in a session that is a quilt.
|
|
18
|
+
export const SEAM_FOR = {
|
|
19
|
+
quilt_git_commit: 'seam_commit (commits your square)',
|
|
20
|
+
quilt_git_status: 'seam_square_status (your square and what changed in it)',
|
|
21
|
+
quilt_git_log: 'seam_log',
|
|
22
|
+
quilt_git_diff: 'seam_diff',
|
|
23
|
+
quilt_git_revert: 'seam_restore (puts a file back as the line has it)',
|
|
24
|
+
quilt_git_pull_requests: 'seam_squares (the squares on a line, with their checks)',
|
|
25
|
+
quilt_git_merge: 'seam_merge (merges a square onto its line)',
|
|
26
|
+
quilt_git_branch: 'seam_open_square (a square of your own on a line)',
|
|
27
|
+
quilt_sync_branch: 'seam_square_status (a square follows its line by itself)',
|
|
28
|
+
quilt_sync_status: 'seam_square_status',
|
|
29
|
+
quilt_github_token: null, // nothing to use instead: the owner's export to GitHub has its own credential
|
|
30
|
+
quilt_request_commit: 'seam_commit on a square of your own, then seam_merge when you may (or ask a person to)',
|
|
31
|
+
quilt_agent_commits: 'seam_log',
|
|
32
|
+
quilt_commit_status: 'seam_square_status',
|
|
33
|
+
quilt_commit_request_done: 'seam_square_status',
|
|
34
|
+
quilt_github_sync: 'seam_square_status'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Why a GitHub tool is refused in a quilt session, naming the Seam tool to use; null when it isn't. */
|
|
38
|
+
export function seamRefusal (tool, quiltId) {
|
|
39
|
+
if (!quiltId || !Object.hasOwn(SEAM_FOR, tool)) return null
|
|
40
|
+
const use = SEAM_FOR[tool] ? `Use ${SEAM_FOR[tool]} instead.` : 'Seam needs no GitHub token: the owner\'s export to GitHub has its own credential.'
|
|
41
|
+
return `This session is the quilt ${quiltId} on Seam, Quilt's source control: it has no GitHub path for agents. ${use} ${SEAM_GUIDE}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const SEAM_GUIDE = 'Its tools come from Seam\'s MCP server (`npx @heyquilt/seam mcp`, the same for every AI tool). Work happens in squares: open one with seam_open_square, commit in it with seam_commit, and seam_merge merges it onto its line. Never use git or GitHub here: a push from your git is refused, and Seam writes GitHub itself when the owner exports there.'
|
|
45
|
+
|
|
46
|
+
/** The line a session's status answers with, in a quilt session; '' otherwise. */
|
|
47
|
+
export function seamNote (quiltId) {
|
|
48
|
+
return quiltId ? `This session is the quilt ${quiltId} on Seam. ${SEAM_GUIDE}` : ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Why the relay gives no GitHub token to a member's git here; null when it may. People keep theirs. */
|
|
52
|
+
export function gitTokenRefusal (quiltId, kind) {
|
|
53
|
+
if (!quiltId || kind !== 'agent') return null
|
|
54
|
+
return `this session is the quilt ${quiltId} on Seam: agents use Seam (seam_commit, seam_merge), not git or GitHub`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A folder's MCP server (src/mcp.js), in a session that is a quilt: wraps `registerTool` so each
|
|
59
|
+
* GitHub tool refuses with the Seam tool to use. `quiltNow()` is the session's link as the relay
|
|
60
|
+
* last said ({ id, slug } or null), asked at call time, so a session that becomes a quilt (or
|
|
61
|
+
* stops being one) changes the answer without a restart. Every other tool is untouched.
|
|
62
|
+
*/
|
|
63
|
+
export function seamGate (registerTool, quiltNow) {
|
|
64
|
+
return (name, spec, handler) => registerTool(name, spec, Object.hasOwn(SEAM_FOR, name)
|
|
65
|
+
? async (...args) => {
|
|
66
|
+
const q = await quiltNow()
|
|
67
|
+
const no = q && seamRefusal(name, q.slug || q.id)
|
|
68
|
+
return no ? { content: [{ type: 'text', text: no }], isError: true } : handler(...args)
|
|
69
|
+
}
|
|
70
|
+
: handler)
|
|
71
|
+
}
|
package/src/session.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// back. Any tool that edits files on disk (Claude Code, Cursor, Codex, vim...)
|
|
3
3
|
// participates automatically; concurrent edits are merged character by
|
|
4
4
|
// character by the CRDT.
|
|
5
|
+
import { describeQuiltPlan } from './session-quilt.js'
|
|
6
|
+
import { SquareFollower } from './session-follow.js'
|
|
7
|
+
import { quiltClient } from '@heyquilt/seam/lib/quilts/client.js'
|
|
5
8
|
import fs from 'node:fs'
|
|
6
9
|
import path from 'node:path'
|
|
7
10
|
import crypto from 'node:crypto'
|
|
@@ -25,7 +28,7 @@ import { migrateDir } from './legacy.js'
|
|
|
25
28
|
import { withComments, addComment as putComment } from './task-comments.js'
|
|
26
29
|
import { readTasks, addTask as putTask, updateTask as patchTask, deleteTask as dropTask, planAutoTask, nextTask, pickupMode, columnName } from './tasks.js'
|
|
27
30
|
import { getSettings } from './settings.js'
|
|
28
|
-
import { HistoryLog, queryHistory, parseSince, currentTask, blameChange, lineDiff } from './history.js'
|
|
31
|
+
import { HistoryLog, queryHistory, parseSince, currentTask, blameChange, lineDiff, legacyTail } from './history.js'
|
|
29
32
|
import { historyMarks, awayChanges, mergeCatchUp, emptyCatchUp } from './catchup.js'
|
|
30
33
|
import { Inbox, mentionsMe, describeEvent } from './inbox.js'
|
|
31
34
|
import { projectRules, renderRules, gatherPlan, planKey } from './project-rules.js'
|
|
@@ -681,6 +684,8 @@ export class Session extends EventEmitter {
|
|
|
681
684
|
this.conn.on('access', (a) => this.setAccess(a))
|
|
682
685
|
this.conn.on('members', (m) => this.setMembers(m))
|
|
683
686
|
this.conn.on('branches', (list) => this.setBranches(list))
|
|
687
|
+
// Seam (plans/quilt-source-control/16): the relay says when the session is a quilt, and which.
|
|
688
|
+
this.conn.on('quilt', (q) => { this.quilt = q; this.emit('quilt', q); if (this.squareFollower) this.squareFollower.onQuilt(q).catch(() => {}) })
|
|
684
689
|
// GitHub moved this folder's branch: fetch and bring it in now, not at the next minute's look.
|
|
685
690
|
this.conn.on('pushed', (p) => {
|
|
686
691
|
if (!this.git || !this.git.branch || p.branch !== this.branchKey() || this.stopped) return
|
|
@@ -3686,6 +3691,70 @@ export class Session extends EventEmitter {
|
|
|
3686
3691
|
* Asked for by an AI (quilt_sync_branch): fetch and bring commits in now, without waiting for
|
|
3687
3692
|
* the next look or another member's folder. What happened: { branch, upstream, moved, busy }.
|
|
3688
3693
|
*/
|
|
3694
|
+
/**
|
|
3695
|
+
* What making this session a quilt would do (plans/quilt-source-control/16), read only: the
|
|
3696
|
+
* relay's dry run (behind QUILT_SOURCE_URL there, the owner's), as { plan, text }.
|
|
3697
|
+
*/
|
|
3698
|
+
async quiltPlan () {
|
|
3699
|
+
if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay; the dry run needs a connection')
|
|
3700
|
+
const r = await this.conn.branchRequest({ op: 'quilt-plan' })
|
|
3701
|
+
return { plan: r.plan, text: describeQuiltPlan(r.plan) }
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
/**
|
|
3705
|
+
* Makes the session a quilt (plans/quilt-source-control/16, "Without history"): the relay does
|
|
3706
|
+
* it, as the owner, with `token` (their Seam sign-in: SEAM_TOKEN, else their Quilt account's).
|
|
3707
|
+
* What it made: { quilt, text }.
|
|
3708
|
+
*/
|
|
3709
|
+
async quiltMigrate (token) {
|
|
3710
|
+
if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay; making the session a quilt needs a connection')
|
|
3711
|
+
if (!token) throw new Error('sign in first (quilt login, or set SEAM_TOKEN): the session becomes a quilt as its owner')
|
|
3712
|
+
const r = await this.conn.branchRequest({ op: 'quilt-migrate', token })
|
|
3713
|
+
const q = r.quilt
|
|
3714
|
+
const lines = [`Session ${this.room || ''} is now the quilt ${q.slug}.`.replace('Session is', 'This session is')]
|
|
3715
|
+
if (q.patch) lines.push(`Patch ${q.patch.no} holds the main branch as it was.`)
|
|
3716
|
+
for (const s of q.squares) lines.push(`Branch ${s.from} is square sq:${s.id.slice(0, 8)}.`)
|
|
3717
|
+
const moved = Object.keys(q.tasks || {}).length
|
|
3718
|
+
if (moved) lines.push(`${moved} task${moved === 1 ? '' : 's'} with a pull request follow${moved === 1 ? 's' : ''} its square.`)
|
|
3719
|
+
if (q.left_out.length) lines.push(`Large files left in the session's storage, not in the quilt: ${q.left_out.join(', ')}.`)
|
|
3720
|
+
return { quilt: q, text: lines.join('\n') }
|
|
3721
|
+
}
|
|
3722
|
+
|
|
3723
|
+
/**
|
|
3724
|
+
* The session's files into its squares' live documents on the relay (layout 3, 16), as the
|
|
3725
|
+
* owner, once it is a quilt: { layout, live, adoption, text }.
|
|
3726
|
+
*/
|
|
3727
|
+
async quiltSwitch (token) {
|
|
3728
|
+
if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay; moving the session needs a connection')
|
|
3729
|
+
if (!token) throw new Error('sign in first (quilt login, or set SEAM_TOKEN): the session moves into its squares as its owner')
|
|
3730
|
+
const r = await this.conn.branchRequest({ op: 'quilt-switch', token })
|
|
3731
|
+
const lines = ['The session\'s files are in its squares now; its branches are kept, read-only.']
|
|
3732
|
+
for (const [b, k] of Object.entries(r.live || {})) lines.push(`${b} is in square sq:${k.replace(/^quilt\//, '').slice(0, 8)}.`)
|
|
3733
|
+
if (r.adoption) lines.push(`Work on the main branch since it became a quilt is in the square "Uncommitted work" (sq:${r.adoption.slice(0, 8)}).`)
|
|
3734
|
+
lines.push('Apps without Seam are asked to update. quilt session migrate --back goes back to the branches.')
|
|
3735
|
+
return { ...r, text: lines.join('\n') }
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
/**
|
|
3739
|
+
* At layout 3 (plans/quilt-source-control/16): `dir` (its own folder) follows this folder's
|
|
3740
|
+
* branch's square, live (session-follow.js), signed in to Seam with `token`. Only with
|
|
3741
|
+
* QUILT_SOURCE_URL set and the relay saying the session is a layout-3 quilt.
|
|
3742
|
+
*/
|
|
3743
|
+
async quiltFollow (token, dir) {
|
|
3744
|
+
if (!token) throw new Error('sign in first (quilt login, or set SEAM_TOKEN): the square is read and written as you')
|
|
3745
|
+
if (!this.squareFollower) this.squareFollower = new SquareFollower(this)
|
|
3746
|
+
const client = process.env.QUILT_SOURCE_URL ? quiltClient({ api: process.env.QUILT_SOURCE_URL, token }) : null
|
|
3747
|
+
const r = await this.squareFollower.follow({ dir: dir ? path.resolve(this.root, String(dir)) : '', client })
|
|
3748
|
+
return { ...r, text: `${r.dir} follows the square sq:${r.square.slice(0, 8)}: edits there are live for everyone on it.` }
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3751
|
+
/** Back to the branches (layout 2) from the kept branch documents. */
|
|
3752
|
+
async quiltUnswitch () {
|
|
3753
|
+
if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay')
|
|
3754
|
+
const r = await this.conn.branchRequest({ op: 'quilt-unswitch' })
|
|
3755
|
+
return { ...r, text: 'The session works from its branches again; the squares keep their live documents.' }
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3689
3758
|
async syncBranchNow () {
|
|
3690
3759
|
// A folder that can't bring commits in itself (no git here, or its fetch fails) asks the
|
|
3691
3760
|
// relay to look at GitHub for its branch now (relay-upstream.js), as a hosted agent can.
|
|
@@ -5634,7 +5703,9 @@ export class Session extends EventEmitter {
|
|
|
5634
5703
|
historyQuery ({ path, by, since, task, limit } = {}) {
|
|
5635
5704
|
const from = parseSince(since)
|
|
5636
5705
|
if (from === undefined) throw new Error('since: use a duration like 2h or 3d, "today", "yesterday", or a date')
|
|
5637
|
-
|
|
5706
|
+
// Once the session is a quilt (the relay says so, src/server.js branchesFields), older changes are its legacy tail.
|
|
5707
|
+
const entries = legacyTail(this.history.entries(), { at: this.quilt && this.quilt.at })
|
|
5708
|
+
return queryHistory(entries, { path, by, since: from ?? undefined, task, limit: Math.min(Number(limit) || 50, 500) })
|
|
5638
5709
|
}
|
|
5639
5710
|
|
|
5640
5711
|
/** `input` is a title, or { title, assignee, forAi, to_ai, tool, files }. "me" means this person. */
|
|
@@ -8077,6 +8148,7 @@ export class Session extends EventEmitter {
|
|
|
8077
8148
|
this.rechecks.clear()
|
|
8078
8149
|
clearInterval(this.reconcileTimer)
|
|
8079
8150
|
clearInterval(this.upstreamPoll)
|
|
8151
|
+
if (this.squareFollower) await this.squareFollower.stop()
|
|
8080
8152
|
clearTimeout(this.clashTimer)
|
|
8081
8153
|
if (this.clashPending) clearTimeout(this.clashPending.timer)
|
|
8082
8154
|
clearTimeout(this.upstreamTimer)
|
|
@@ -14,9 +14,10 @@ import path from 'node:path'
|
|
|
14
14
|
import * as Y from 'yjs'
|
|
15
15
|
import { Connection, REMOTE } from '../connection.js'
|
|
16
16
|
import { applyTextDiff } from '../textdiff.js'
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
17
|
+
// Seam's block folders and ids come from Seam's client package (@heyquilt/seam), never Seam's server code.
|
|
18
|
+
import { blobId, under } from '@heyquilt/seam/lib/quilts/records.js'
|
|
19
|
+
import { isText } from '@heyquilt/seam/lib/quilts/textdiff.js'
|
|
20
|
+
import { checkout, findBlock, saveBlock, localChanges, BLOCK_DIR } from '@heyquilt/seam/lib/quilts/block.js'
|
|
20
21
|
import { liveKey, liveMaps, liveHead, entryOf, entryId, overlay } from './live-square.js'
|
|
21
22
|
|
|
22
23
|
export const MAX_LIVE_TEXT = 2 * 1024 * 1024
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// from the overlay, one transaction moves `head` and drops every entry whose content is the one
|
|
18
18
|
// that was committed. An entry typed into after the commit read it stays, against the new head.
|
|
19
19
|
import * as Y from 'yjs'
|
|
20
|
-
import { blobId } from '
|
|
20
|
+
import { blobId } from '@heyquilt/seam/lib/quilts/records.js'
|
|
21
21
|
|
|
22
22
|
export const LIVE_PREFIX = 'quilt/'
|
|
23
23
|
export const liveKey = (squareId) => `${LIVE_PREFIX}${squareId}`
|