heyquilt 0.3.122 → 0.3.140
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASES.md +279 -17
- package/bin/quilt.js +62 -152
- package/desktop/main.js +16 -2
- package/package.json +3 -2
- package/src/account.js +0 -29
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +16 -38
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/session-migrate.js +7 -4
- package/src/api/routes/sessions.js +73 -1
- package/src/api/routes/teams.js +0 -23
- package/src/api/server.js +17 -25
- package/src/api/supabase-store.js +43 -56
- package/src/autowork.js +182 -16
- package/src/branch-view.js +6 -131
- package/src/connection.js +36 -9
- package/src/control.js +131 -48
- package/src/duties.js +1 -1
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/github-commit.js +3 -315
- package/src/history.js +13 -3
- package/src/mcp.js +93 -167
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-ask.js +3 -66
- package/src/relay-cluster.js +3 -5
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +154 -432
- package/src/relay-prune.js +6 -145
- package/src/relay-turn-sync.js +1 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1516 -1383
- package/src/session-follow.js +461 -27
- package/src/session-layout3.js +410 -4
- package/src/session-migrate.js +15 -11
- package/src/session-quilt.js +1 -1
- package/src/session-seam.js +233 -20
- package/src/session.js +745 -3438
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +504 -33
- package/src/source-live/live-square.js +16 -5
- package/src/status.js +7 -58
- package/src/turn-sync.js +15 -2
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/changes.js +5 -14
- package/src/ui/chat.js +9 -4
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -4
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -28
- package/src/ui/home.js +3 -223
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +377 -489
- package/src/ui/tree.js +2 -8
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui/workspaces.js +2 -122
- package/src/ui-server.js +81 -66
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/api/routes/github.js +0 -393
- package/src/branches.js +0 -194
- package/src/clash.js +0 -128
- package/src/commit-request.js +0 -59
- package/src/commit.js +0 -213
- package/src/git-hook.js +0 -48
- package/src/git.js +0 -147
- package/src/gitignore.js +0 -49
- package/src/gitstate.js +0 -1033
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/accounts.js +0 -136
- package/src/quilts/affected.js +0 -132
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/checks.js +0 -308
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -536
- package/src/quilts/client.js +0 -137
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -296
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -171
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export-remote.js +0 -156
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-follow.js +0 -236
- package/src/quilts/import-git.js +0 -294
- package/src/quilts/import.js +0 -196
- package/src/quilts/index.js +0 -94
- package/src/quilts/keys.js +0 -186
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -501
- package/src/quilts/oauth.js +0 -202
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -208
- package/src/quilts/routes.js +0 -186
- package/src/quilts/runner.js +0 -95
- package/src/quilts/server.js +0 -144
- package/src/quilts/service.js +0 -963
- package/src/quilts/setup.js +0 -41
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -844
- package/src/quilts/store-rpc.js +0 -90
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -403
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -121
- package/src/quilts/xdiff.js +0 -435
- package/src/relay-ci.js +0 -111
- package/src/relay-clash.js +0 -214
- package/src/relay-commit.js +0 -918
- package/src/relay-filekey.js +0 -46
- package/src/relay-git.js +0 -274
- package/src/relay-githttp.js +0 -166
- package/src/relay-github.js +0 -109
- package/src/relay-outbound.js +0 -1295
- package/src/relay-repo-moves.js +0 -43
- package/src/relay-sync.js +0 -795
- package/src/relay-upstream.js +0 -889
- package/src/sync-words.js +0 -42
- package/src/ui/branches.js +0 -111
- package/src/upstream.js +0 -192
package/src/quilts/setup.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// Which store and object store Quilt source control runs on, from the environment.
|
|
2
|
-
// QUILT_SOURCE=1 turns it on (off: the API has no /v1/quilts routes)
|
|
3
|
-
// QUILT_SOURCE_DATA=<dir> local: the memory store's journal and the packs on disk (default ./quilt-source-data)
|
|
4
|
-
// QUILT_SOURCE_STORE=supabase rows and packs in their own Supabase project ("Quilt Source"), apart
|
|
5
|
-
// from the accounts project: QUILT_SOURCE_SUPABASE_URL and
|
|
6
|
-
// QUILT_SOURCE_SUPABASE_KEY (its service role key); the rows from the
|
|
7
|
-
// *_quilts.sql migration, the packs in its bucket QUILT_SOURCE_BUCKET
|
|
8
|
-
// (default quilt-packs)
|
|
9
|
-
// SEAM_IMPORT_SCRATCH=<dir> where imports from a git remote keep their mirrors (default the temp folder)
|
|
10
|
-
// SEAM_SECRET_<NAME> a credential an import names (`--credential name`): user:token or a token
|
|
11
|
-
import path from 'node:path'
|
|
12
|
-
import os from 'node:os'
|
|
13
|
-
import { createQuilts } from './index.js'
|
|
14
|
-
import { createMemoryQuiltStore } from './store-memory.js'
|
|
15
|
-
import { BlobstoreObjects, diskObjects } from './objects.js'
|
|
16
|
-
|
|
17
|
-
export async function quiltsFromEnv (env, { memory = false, log = () => {} } = {}) {
|
|
18
|
-
if (!/^(1|true|yes)$/i.test(env.QUILT_SOURCE || '')) return null
|
|
19
|
-
if (env.QUILT_SOURCE_STORE === 'supabase') {
|
|
20
|
-
const { createSupabaseQuiltStore } = await import('./store-supabase.js')
|
|
21
|
-
const { SupabaseBucket } = await import('./bucket.js')
|
|
22
|
-
// Never the accounts project: its load and its mistakes stay away from people's accounts.
|
|
23
|
-
for (const k of ['QUILT_SOURCE_SUPABASE_URL', 'QUILT_SOURCE_SUPABASE_KEY']) if (!env[k]) throw new Error(`QUILT_SOURCE_STORE=supabase needs ${k}`)
|
|
24
|
-
const url = env.QUILT_SOURCE_SUPABASE_URL
|
|
25
|
-
const key = env.QUILT_SOURCE_SUPABASE_KEY
|
|
26
|
-
if (env.SUPABASE_URL && url.replace(/\/+$/, '') === env.SUPABASE_URL.replace(/\/+$/, '')) throw new Error('QUILT_SOURCE_SUPABASE_URL must be its own project, not the accounts project')
|
|
27
|
-
const store = createSupabaseQuiltStore({ url, serviceKey: key })
|
|
28
|
-
const objects = new BlobstoreObjects(new SupabaseBucket({ url, key, bucket: env.QUILT_SOURCE_BUCKET || 'quilt-packs' }))
|
|
29
|
-
log(`quilt source control: rows and packs in ${new URL(url).host}`)
|
|
30
|
-
const quilts = createQuilts({ store, objects, events: { log }, siteUrl: env.SEAM_SITE_URL || undefined, importFollow: { scratch: env.SEAM_IMPORT_SCRATCH || path.join(os.tmpdir(), 'seam-imports') } })
|
|
31
|
-
quilts.follower.start()
|
|
32
|
-
return quilts
|
|
33
|
-
}
|
|
34
|
-
const dir = path.resolve(env.QUILT_SOURCE_DATA || './quilt-source-data')
|
|
35
|
-
const store = createMemoryQuiltStore({ file: path.join(dir, 'journal.jsonl') })
|
|
36
|
-
log(`quilt source control: local store in ${dir}${memory ? '' : ' (QUILT_SOURCE_DATA)'}`)
|
|
37
|
-
// The local prototype may POST events to webhooks on this machine; the hosted one may not.
|
|
38
|
-
const quilts = createQuilts({ store, objects: diskObjects(path.join(dir, 'packs')), events: { allowLocalWebhooks: true, log }, siteUrl: env.SEAM_SITE_URL || undefined, importFollow: { scratch: env.SEAM_IMPORT_SCRATCH || path.join(dir, 'imports'), allowLocal: true } })
|
|
39
|
-
quilts.follower.start()
|
|
40
|
-
return quilts
|
|
41
|
-
}
|
package/src/quilts/signing.js
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
// Signed commits: a person or an agent adds an Ed25519 public key to their account, signs a commit
|
|
2
|
-
// with the private key (which never leaves their computer), and anyone reading the log can check
|
|
3
|
-
// the signature (seam log --show-signature). A commit's id is the hash of its whole record (tree,
|
|
4
|
-
// parents, author, message, quilt), so signing the id signs all of it.
|
|
5
|
-
//
|
|
6
|
-
// What is signed is the text `seam-commit <id>\n`. Only the commit's author, or the person it was
|
|
7
|
-
// made for (on_behalf_of), may sign it, with one of their own keys. A key is removed, never
|
|
8
|
-
// deleted: signatures made with it then read as "removed_key", so a stolen key stops vouching.
|
|
9
|
-
import crypto from 'node:crypto'
|
|
10
|
-
import fs from 'node:fs'
|
|
11
|
-
import os from 'node:os'
|
|
12
|
-
import path from 'node:path'
|
|
13
|
-
import { QuiltError } from './errors.js'
|
|
14
|
-
|
|
15
|
-
export const MAX_KEYS = 20
|
|
16
|
-
const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') // Ed25519 SubjectPublicKeyInfo header
|
|
17
|
-
|
|
18
|
-
/** The bytes a signature covers. */
|
|
19
|
-
export const signedText = (commitId) => `seam-commit ${commitId}\n`
|
|
20
|
-
|
|
21
|
-
/** A public key (SPKI PEM, or base64 of the 32 raw bytes) as base64 of its raw bytes, or null. */
|
|
22
|
-
export function rawPublicKey (input) {
|
|
23
|
-
const s = String(input || '').trim()
|
|
24
|
-
if (!s) return null
|
|
25
|
-
try {
|
|
26
|
-
if (s.includes('BEGIN')) {
|
|
27
|
-
const k = crypto.createPublicKey(s)
|
|
28
|
-
if (k.asymmetricKeyType !== 'ed25519') return null
|
|
29
|
-
return k.export({ format: 'der', type: 'spki' }).subarray(SPKI_PREFIX.length).toString('base64')
|
|
30
|
-
}
|
|
31
|
-
const b = Buffer.from(s, 'base64')
|
|
32
|
-
return b.length === 32 ? b.toString('base64') : null
|
|
33
|
-
} catch { return null }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const keyObject = (raw) => crypto.createPublicKey({ key: Buffer.concat([SPKI_PREFIX, Buffer.from(raw, 'base64')]), format: 'der', type: 'spki' })
|
|
37
|
-
|
|
38
|
-
/** A key's id: `ed25519:` and the first 16 hex of the sha256 of its raw bytes. */
|
|
39
|
-
export const keyId = (raw) => 'ed25519:' + crypto.createHash('sha256').update(Buffer.from(raw, 'base64')).digest('hex').slice(0, 16)
|
|
40
|
-
|
|
41
|
-
export function verifySignature (raw, commitId, signature) {
|
|
42
|
-
try { return crypto.verify(null, Buffer.from(signedText(commitId)), keyObject(raw), Buffer.from(String(signature), 'base64')) } catch { return false }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ---- the client side: the private key on this computer ----
|
|
46
|
-
|
|
47
|
-
/** Where this computer's signing key lives: SEAM_SIGNING_KEY, else ~/.seam/signing-key.pem. */
|
|
48
|
-
export const signingKeyFile = () => process.env.SEAM_SIGNING_KEY || path.join(process.env.SEAM_HOME || path.join(os.homedir(), '.seam'), 'signing-key.pem')
|
|
49
|
-
|
|
50
|
-
/** Makes a new key pair on this computer (refused when one is there). Returns the public half. */
|
|
51
|
-
export function makeLocalKey (file = signingKeyFile()) {
|
|
52
|
-
if (fs.existsSync(file)) throw new Error(`There is already a signing key at ${file}.`)
|
|
53
|
-
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
|
|
54
|
-
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
55
|
-
fs.writeFileSync(file, privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })
|
|
56
|
-
return publicKey.export({ format: 'pem', type: 'spki' })
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** This computer's key: { sign(commitId), public_key, key } or null when there is none. */
|
|
60
|
-
export function localKey (file = signingKeyFile()) {
|
|
61
|
-
if (!fs.existsSync(file)) return null
|
|
62
|
-
const priv = crypto.createPrivateKey(fs.readFileSync(file))
|
|
63
|
-
const pub = crypto.createPublicKey(priv).export({ format: 'pem', type: 'spki' })
|
|
64
|
-
const raw = rawPublicKey(pub)
|
|
65
|
-
return { public_key: pub, key: keyId(raw), sign: (commitId) => crypto.sign(null, Buffer.from(signedText(commitId)), priv).toString('base64') }
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Signs a commit with this computer's key and sends the signature. */
|
|
69
|
-
export async function signWithLocalKey (client, quilt, rev) {
|
|
70
|
-
const k = localKey()
|
|
71
|
-
if (!k) throw new Error('No signing key on this computer: run seam key add first.')
|
|
72
|
-
const id = (await client.describe(quilt, rev || 'main')).commit
|
|
73
|
-
if (!id) throw new Error(`No commit at ${rev || 'main'}.`)
|
|
74
|
-
return client.sign(quilt, id, { key: k.key, signature: k.sign(id) })
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ---- the server side ----
|
|
78
|
-
|
|
79
|
-
export function signingOps (svc) {
|
|
80
|
-
const { store, now, quiltFor, resolve, getCommit, fromStore } = svc.internals
|
|
81
|
-
|
|
82
|
-
const keyView = (r) => ({ key: r.id, name: r.name || '', public_key: r.public_key, added_at: r.added_at, ...(r.removed_at ? { removed_at: r.removed_at } : {}) })
|
|
83
|
-
|
|
84
|
-
/** POST /signing-keys { public_key, name? }: adds one of your keys. */
|
|
85
|
-
async function addKey (me, _ref, { public_key: pk, name = '' } = {}) {
|
|
86
|
-
const raw = rawPublicKey(pk)
|
|
87
|
-
if (!raw) throw new QuiltError(400, 'bad_request', 'public_key: an Ed25519 public key (PEM, or base64 of its 32 bytes)')
|
|
88
|
-
if (String(name).length > 100) throw new QuiltError(400, 'bad_request', 'a key name is up to 100 characters')
|
|
89
|
-
const mine = await store.signingKeys(me.member_id)
|
|
90
|
-
if (mine.filter((k) => !k.removed_at).length >= MAX_KEYS) throw new QuiltError(409, 'too_many_keys', `up to ${MAX_KEYS} keys each`)
|
|
91
|
-
try {
|
|
92
|
-
return keyView(await store.addSigningKey({ id: keyId(raw), member: me.member_id, public_key: raw, name: String(name), added_at: now() }))
|
|
93
|
-
} catch (err) { throw fromStore(err) }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** GET /signing-keys: your keys (or a member's, with ?member=). */
|
|
97
|
-
async function keys (me, _ref, { member } = {}) {
|
|
98
|
-
return { member: member || me.member_id, keys: (await store.signingKeys(member || me.member_id)).map(keyView) }
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** DELETE /signing-keys/:key: removes one of your keys; its signatures stop counting. */
|
|
102
|
-
async function removeKey (me, _ref, key) {
|
|
103
|
-
const row = await store.signingKey(String(key))
|
|
104
|
-
if (!row || row.member !== me.member_id) throw new QuiltError(404, 'not_found', `you have no key ${key}`)
|
|
105
|
-
if (row.removed_at) return keyView(row)
|
|
106
|
-
try { return keyView(await store.removeSigningKey({ id: row.id, member: me.member_id, at: now() })) } catch (err) { throw fromStore(err) }
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** POST /commits/:rev/signatures { key, signature }: signs a commit you made (or that was made for you). */
|
|
110
|
-
async function sign (me, ref, rev, { key, signature } = {}) {
|
|
111
|
-
const q = await quiltFor(me, ref, 'write')
|
|
112
|
-
const id = await resolve(q.id, String(rev || ''))
|
|
113
|
-
if (!id) throw new QuiltError(404, 'not_found', `no commit at ${rev}`)
|
|
114
|
-
const c = await getCommit(q.id, id)
|
|
115
|
-
const by = c.author?.member_id
|
|
116
|
-
if (me.member_id !== by && me.member_id !== c.on_behalf_of) throw new QuiltError(403, 'not_author', 'only the commit\'s author, or whom it was made for, signs it')
|
|
117
|
-
const row = key ? await store.signingKey(String(key)) : null
|
|
118
|
-
if (!row || row.member !== me.member_id || row.removed_at) throw new QuiltError(400, 'bad_key', `${key || 'key'} is not one of your keys (seam key add)`)
|
|
119
|
-
if (!verifySignature(row.public_key, id, signature)) throw new QuiltError(422, 'bad_signature', `the signature does not match ${id.slice(0, 12)} and ${row.id}`)
|
|
120
|
-
try {
|
|
121
|
-
await store.addSignature({ quilt_id: q.id, commit: id, key: row.id, signer: me.member_id, signature: String(signature), at: now() })
|
|
122
|
-
} catch (err) { throw fromStore(err) }
|
|
123
|
-
return { commit: id, short: id.slice(0, 12), signature: { status: 'good', key: row.id, signer: me.member_id } }
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Each commit's signature, checked now: good, bad, removed_key, or none. */
|
|
127
|
-
async function signaturesFor (q, commits) {
|
|
128
|
-
const rows = await store.signaturesOf(q, commits.map((c) => c.id))
|
|
129
|
-
const keyRows = new Map()
|
|
130
|
-
const out = new Map()
|
|
131
|
-
for (const r of rows) {
|
|
132
|
-
if (!keyRows.has(r.key)) keyRows.set(r.key, await store.signingKey(r.key))
|
|
133
|
-
const k = keyRows.get(r.key)
|
|
134
|
-
const status = !k || !verifySignature(k.public_key, r.commit, r.signature) ? 'bad' : k.removed_at ? 'removed_key' : 'good'
|
|
135
|
-
const prev = out.get(r.commit)
|
|
136
|
-
if (!prev || (status === 'good' && prev.status !== 'good')) out.set(r.commit, { status, key: r.key, signer: r.signer, at: r.at })
|
|
137
|
-
}
|
|
138
|
-
return out
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Adds `signature` to each commit of a log answer. */
|
|
142
|
-
async function withSignatures (me, ref, answer) {
|
|
143
|
-
const q = await quiltFor(me, ref, 'read')
|
|
144
|
-
const sigs = await signaturesFor(q.id, answer.commits || [])
|
|
145
|
-
return { ...answer, commits: (answer.commits || []).map((c) => ({ ...c, signature: sigs.get(c.id) || { status: 'none' } })) }
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return { addKey, signingKeys: keys, removeKey, sign, withSignatures }
|
|
149
|
-
}
|
package/src/quilts/squares.js
DELETED
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
// The rest of a square's lifecycle (plans/quilt-source-control/06): handoff, abandon, reopen,
|
|
2
|
-
// fold, split, merging one square into another (07), and status's merged_under_you and
|
|
3
|
-
// would_conflict (06). Each state move is one square log row and a compare-and-swap on the
|
|
4
|
-
// square row (store.setSquare expect).
|
|
5
|
-
import { QuiltError, uuidv7, squareShort } from './service.js'
|
|
6
|
-
import { under, cleanPrefix } from './records.js'
|
|
7
|
-
|
|
8
|
-
const MAX_HANDOFF_TEXT = 2000
|
|
9
|
-
|
|
10
|
-
export function squareOps (svc, m = {}) {
|
|
11
|
-
const { store, now, quiltFor, squareByRef, squareView, fromStore, resolve, getCommit, snapshot, reader, seal, writable, once, commitView, pathPatches } = svc.internals
|
|
12
|
-
const move = async (args) => { try { return await store.setSquare(args) } catch (e) { throw fromStore(e) } }
|
|
13
|
-
|
|
14
|
-
/** handoff: { to, expect_head?, note? } — the holder (or an admin) gives the square to another member. */
|
|
15
|
-
async function handoff (me, ref, sqRef, body = {}) {
|
|
16
|
-
const q = await quiltFor(me, ref, 'write')
|
|
17
|
-
const sq = await squareByRef(q.id, sqRef)
|
|
18
|
-
if (sq.holder !== me.member_id && q.level !== 'admin') throw new QuiltError(403, 'not_holder', `this square is held by ${sq.holder}`, { holder: sq.holder })
|
|
19
|
-
if (sq.state !== 'open') throw new QuiltError(409, 'square_state', `the square is ${sq.state}`)
|
|
20
|
-
const to = String(body.to || '')
|
|
21
|
-
if (!to) throw new QuiltError(400, 'bad_request', 'give `to`: the member to hand the square to')
|
|
22
|
-
const level = await store.memberLevel(q.id, to)
|
|
23
|
-
if (level !== 'write' && level !== 'admin') throw new QuiltError(403, 'forbidden', `${to} cannot write to this quilt`)
|
|
24
|
-
const note = String(body.note || body.context || '')
|
|
25
|
-
if (note.length > MAX_HANDOFF_TEXT) throw new QuiltError(400, 'bad_request', `the note is at most ${MAX_HANDOFF_TEXT} characters`)
|
|
26
|
-
const expect = { holder: sq.holder, state: 'open', ...(body.expect_head !== undefined ? { head: body.expect_head } : {}) }
|
|
27
|
-
const row = await move({ square_id: sq.id, op: 'handoff', expect, actor: me.member_id, at: now(), patch: { holder: to, writers: (sq.writers || []).filter((w) => w !== to) }, log: { from: sq.holder, to, ...(note ? { context: note } : {}) } })
|
|
28
|
-
return squareView(row)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** abandon: { expect_head? } — the work is not wanted; the id stays, and it can be reopened. */
|
|
32
|
-
async function abandon (me, ref, sqRef, body = {}) {
|
|
33
|
-
const q = await quiltFor(me, ref, 'write')
|
|
34
|
-
const sq = await squareByRef(q.id, sqRef)
|
|
35
|
-
if (sq.holder !== me.member_id && q.level !== 'admin') throw new QuiltError(403, 'not_holder', `this square is held by ${sq.holder}`, { holder: sq.holder })
|
|
36
|
-
if (sq.state !== 'open') throw new QuiltError(409, 'square_state', `the square is ${sq.state}`)
|
|
37
|
-
const expect = { state: 'open', ...(body.expect_head !== undefined ? { head: body.expect_head } : {}) }
|
|
38
|
-
return squareView(await move({ square_id: sq.id, op: 'abandon', expect, actor: me.member_id, at: now(), patch: { state: 'abandoned' }, log: { snapshot_commit: sq.head || null } }))
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** reopen: abandoned → open; the reopener becomes the holder. */
|
|
42
|
-
async function reopen (me, ref, sqRef) {
|
|
43
|
-
const q = await quiltFor(me, ref, 'write')
|
|
44
|
-
const sq = await squareByRef(q.id, sqRef)
|
|
45
|
-
if (sq.state !== 'abandoned') throw new QuiltError(409, 'square_state', `only an abandoned square reopens; this one is ${sq.state}`)
|
|
46
|
-
return squareView(await move({ square_id: sq.id, op: 'reopen', expect: { state: 'abandoned' }, actor: me.member_id, at: now(), patch: { state: 'open', holder: me.member_id, writers: (sq.writers || []).filter((w) => w !== me.member_id) } }))
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const authorOf = (me) => ({ member_id: me.member_id, kind: me.kind, name: me.name, ...(me.tool ? { tool: me.tool } : {}) })
|
|
50
|
-
const toChange = (p, e) => !e ? { path: p, op: 'delete' } : e.mode === 'conflict' ? { path: p, op: 'conflict', term: e.term } : { path: p, op: 'put', blob: e.blob, mode: e.mode, size: e.size }
|
|
51
|
-
|
|
52
|
-
/** Seals a made commit and writes it on its square (compare-and-swap on the head). */
|
|
53
|
-
async function writeMade (q, made, expectHead, me, objects, payload, squarePatch) {
|
|
54
|
-
await seal(q, [...objects, made.object], { by: me.member_id })
|
|
55
|
-
await store.stageFiles({ quilt_id: q, commit_id: made.id, files: made.record.changes })
|
|
56
|
-
try {
|
|
57
|
-
return await store.commit({ commit: { ...made.record, id: made.id, at: now() }, expect_head: expectHead || null, actor: me.member_id, at: now(), payload, ...(squarePatch ? { square_patch: squarePatch } : {}) })
|
|
58
|
-
} catch (err) { throw fromStore(err) }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* The merge base of two commits (07, 08): the common ancestor with the highest seq. Walks both
|
|
63
|
-
* histories newest first (a parent's seq is always below its child's), so it reads only the
|
|
64
|
-
* commits since the two sides parted. Returns { base, bases } (bases: candidates seen).
|
|
65
|
-
*/
|
|
66
|
-
async function mergeBase (q, a, b) {
|
|
67
|
-
if (!a || !b) return { base: null, bases: 0 }
|
|
68
|
-
const flags = new Map(); const info = new Map(); const done = new Set(); const queue = []
|
|
69
|
-
const mark = async (id, f) => {
|
|
70
|
-
const cur = flags.get(id) || 0
|
|
71
|
-
if ((cur | f) === cur) return
|
|
72
|
-
flags.set(id, cur | f)
|
|
73
|
-
if (!info.has(id)) { const c = await getCommit(q, id); info.set(id, { seq: c.seq, parents: c.parents }) }
|
|
74
|
-
if (!done.has(id) && !queue.includes(id)) queue.push(id)
|
|
75
|
-
}
|
|
76
|
-
await mark(a, 1); await mark(b, 2)
|
|
77
|
-
while (queue.length) {
|
|
78
|
-
let at = 0
|
|
79
|
-
for (let i = 1; i < queue.length; i++) if (info.get(queue[i]).seq > info.get(queue[at]).seq) at = i
|
|
80
|
-
const id = queue.splice(at, 1)[0]
|
|
81
|
-
done.add(id)
|
|
82
|
-
const f = flags.get(id)
|
|
83
|
-
if (f === 3) return { base: id, bases: 1 + queue.filter((x) => flags.get(x) === 3).length }
|
|
84
|
-
for (const p of info.get(id).parents) await mark(p, f)
|
|
85
|
-
}
|
|
86
|
-
return { base: null, bases: 0 }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* merge into a square (07): { from: sq:… | revision, expect_head?, message? } writes a commit
|
|
91
|
-
* in the square with parents [head, from] after a three-way merge against their merge base.
|
|
92
|
-
* Conflicts are kept as terms in the result; `from` stays open (fold closes it).
|
|
93
|
-
*/
|
|
94
|
-
async function mergeSquare (me, ref, intoRef, body = {}) {
|
|
95
|
-
const q = await quiltFor(me, ref, 'write')
|
|
96
|
-
const into = await writable(me, q, intoRef)
|
|
97
|
-
const expect = body.expect_head === undefined ? into.head : body.expect_head
|
|
98
|
-
if (!body.from) throw new QuiltError(400, 'bad_request', 'give `from`: the square (sq:…) or revision to merge in')
|
|
99
|
-
const fromSq = /^sq:/.test(String(body.from)) || /^[0-9a-f-]{36}$/.test(String(body.from)) ? await squareByRef(q.id, body.from).catch(() => null) : null
|
|
100
|
-
const theirs = fromSq ? (fromSq.head || fromSq.base) : await resolve(q.id, body.from)
|
|
101
|
-
if (!theirs) throw new QuiltError(404, 'not_found', `${body.from} names no commit`)
|
|
102
|
-
if (fromSq && fromSq.id === into.id) throw new QuiltError(400, 'bad_request', 'a square cannot merge itself')
|
|
103
|
-
return once('merge-square', into.id, expect, { from: theirs, message: body.message || null }, async () => {
|
|
104
|
-
if (into.state !== 'open') throw new QuiltError(409, into.state === 'merging' ? 'square_merging' : 'square_closed', `the square is ${into.state}`)
|
|
105
|
-
if ((into.head || null) !== (expect || null)) throw new QuiltError(409, 'head_moved', 'the square moved on since you looked', { head: into.head })
|
|
106
|
-
const ours = into.head || into.base
|
|
107
|
-
const { base, bases } = await mergeBase(q.id, ours, theirs)
|
|
108
|
-
if (base === theirs) return { ok: true, noop: true, detail: 'already merged: that work is in this square', square: await squareView(into) }
|
|
109
|
-
const sides = { ours: { commit: ours, square: into.id, by: me.member_id }, theirs: { commit: theirs, ...(fromSq ? { square: fromSq.id, by: fromSq.holder } : { by: (await getCommit(q.id, theirs)).author.member_id }) }, base: { commit: base } }
|
|
110
|
-
const comb = await m.combine(q.id, base, ours, theirs, sides, { reason: 'merge', against: theirs })
|
|
111
|
-
const onto = await m.entryMap(q.id, ours, comb.finals.keys())
|
|
112
|
-
const changes = m.changesOnto(onto, comb.finals)
|
|
113
|
-
const label = fromSq ? `square ${squareShort(fromSq.id)} "${fromSq.title}"` : `${String(body.from)}`
|
|
114
|
-
const made = m.makeCommit(q.id, into, { parents: [ours, theirs].filter(Boolean), author: authorOf(me), onBehalfOf: me.on_behalf_of, message: body.message || `Merge ${label} into ${squareShort(into.id)}`, changes })
|
|
115
|
-
const row = await writeMade(q.id, made, into.head, me, [...comb.blobs, ...comb.terms], { merged_from: theirs, ...(fromSq ? { from_square: fromSq.id } : {}), base, bases })
|
|
116
|
-
return { ok: true, seq: row.seq, result: commitView({ ...row, changes }), base, bases, combined: comb.combined, conflicted: comb.conflicts.length, terms: comb.conflicts, square: await squareView(await store.getSquare(into.id)) }
|
|
117
|
-
})
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* fold (06): { into, expect_head? } ends this square and keeps its work: one commit on `into`
|
|
122
|
-
* whose change is this square's head against its base (predecessor = the head; overlaps become
|
|
123
|
-
* conflict terms, D10), then this square is `folded` with `folded_into`.
|
|
124
|
-
*/
|
|
125
|
-
async function fold (me, ref, fromRef, body = {}) {
|
|
126
|
-
const q = await quiltFor(me, ref, 'write')
|
|
127
|
-
const from = await squareByRef(q.id, fromRef)
|
|
128
|
-
if (!body.into) throw new QuiltError(400, 'bad_request', 'give `into`: the square to fold this one into')
|
|
129
|
-
const into = await squareByRef(q.id, body.into)
|
|
130
|
-
if (from.id === into.id) throw new QuiltError(400, 'bad_request', 'a square cannot fold into itself')
|
|
131
|
-
if (q.level !== 'admin' && (from.holder !== me.member_id || into.holder !== me.member_id)) throw new QuiltError(403, 'not_holder', 'fold needs you to hold both squares (hand the other one to yourself first)', { holders: [from.holder, into.holder] })
|
|
132
|
-
for (const s of [from, into]) if (s.state !== 'open') throw new QuiltError(409, 'square_state', `${squareShort(s.id)} is ${s.state}; open a new square from the patch instead`)
|
|
133
|
-
const expect = body.expect_head === undefined ? from.head : body.expect_head
|
|
134
|
-
if ((from.head || null) !== (expect || null)) throw new QuiltError(409, 'head_moved', 'the square moved on since you looked', { head: from.head })
|
|
135
|
-
return once('fold', from.id, expect, { into: into.id }, async () => {
|
|
136
|
-
let commit = null; let terms = []
|
|
137
|
-
if (from.head) {
|
|
138
|
-
const ours = into.head || into.base
|
|
139
|
-
const sides = { ours: { commit: ours, square: into.id, by: into.holder }, theirs: { commit: from.head, square: from.id, by: from.holder }, base: { commit: from.base } }
|
|
140
|
-
const comb = await m.combine(q.id, from.base, ours, from.head, sides, { reason: 'merge', against: from.head })
|
|
141
|
-
const finals = new Map([...comb.finals].filter(([p]) => comb.theirsPaths.has(p)))
|
|
142
|
-
const changes = m.changesOnto(await m.entryMap(q.id, ours, finals.keys()), finals)
|
|
143
|
-
if (changes.length) {
|
|
144
|
-
const made = m.makeCommit(q.id, into, { parents: [ours].filter(Boolean), predecessor: from.head, author: authorOf(me), onBehalfOf: me.on_behalf_of, message: body.message || `Fold ${squareShort(from.id)}: ${from.title}`, changes })
|
|
145
|
-
const row = await writeMade(q.id, made, into.head, me, [...comb.blobs, ...comb.terms], { folded_from: from.id, from_head: from.head })
|
|
146
|
-
commit = commitView({ ...row, changes })
|
|
147
|
-
terms = comb.conflicts.filter((c) => finals.has(c.path))
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
const row = await move({ square_id: from.id, op: 'fold', expect: { state: 'open', head: from.head || null }, actor: me.member_id, at: now(), patch: { state: 'folded', folded_into: into.id }, log: { into: into.id, commit: commit ? commit.id : null } })
|
|
151
|
-
return { ok: true, folded: await squareView(row), result: commit, conflicted: terms.length, terms, square: await squareView(await store.getSquare(into.id)) }
|
|
152
|
-
})
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* split (06): { by: 'paths', groups: [[path or dir, ...], ...], titles?, expect_head? }. Each
|
|
157
|
-
* group of the square's changed paths moves to a new sibling square (same base and line) as one
|
|
158
|
-
* commit with predecessor = the head; the largest group and the paths no group covers stay,
|
|
159
|
-
* and the square's head is rewritten to that remainder.
|
|
160
|
-
*/
|
|
161
|
-
async function split (me, ref, sqRef, body = {}) {
|
|
162
|
-
const q = await quiltFor(me, ref, 'write')
|
|
163
|
-
const sq = await writable(me, q, sqRef)
|
|
164
|
-
if ((body.by || 'paths') !== 'paths') throw new QuiltError(400, 'bad_request', 'this prototype splits by paths only (claims and tasks are not on the quilt server yet)')
|
|
165
|
-
if (sq.state !== 'open') throw new QuiltError(409, 'square_state', `the square is ${sq.state}`)
|
|
166
|
-
const expect = body.expect_head === undefined ? sq.head : body.expect_head
|
|
167
|
-
if ((sq.head || null) !== (expect || null)) throw new QuiltError(409, 'head_moved', 'the square moved on since you looked', { head: sq.head })
|
|
168
|
-
if (!sq.head) throw new QuiltError(400, 'bad_request', 'the square has no commits to split')
|
|
169
|
-
const groups = (body.groups || []).map((g) => (Array.isArray(g) ? g : [g]).map(cleanPrefix))
|
|
170
|
-
if (groups.length < 1 || groups.some((g) => !g.length)) throw new QuiltError(400, 'bad_request', 'give groups: [[paths], ...], one per new square')
|
|
171
|
-
return once('split', sq.id, expect, { groups, titles: body.titles || null }, async () => {
|
|
172
|
-
const d = await reader(q.id).diff(await snapshot(q.id, sq.base), await snapshot(q.id, sq.head))
|
|
173
|
-
const sets = groups.map(() => [])
|
|
174
|
-
const rest = []
|
|
175
|
-
for (const e of d) { const i = groups.findIndex((g) => g.some((p) => under(e.path, p))); (i < 0 ? rest : sets[i]).push(e) }
|
|
176
|
-
const empty = sets.findIndex((s) => !s.length)
|
|
177
|
-
if (empty >= 0) throw new QuiltError(400, 'bad_request', `group ${empty + 1} (${groups[empty].join(', ')}) covers no path the square changed`)
|
|
178
|
-
// The keeper: the paths no group covers, or the largest group when every path is covered.
|
|
179
|
-
let keep = -1
|
|
180
|
-
if (!rest.length) { keep = 0; for (let i = 1; i < sets.length; i++) if (sets[i].length > sets[keep].length) keep = i }
|
|
181
|
-
const kept = keep < 0 ? rest : [...sets[keep], ...rest]
|
|
182
|
-
const opened = []
|
|
183
|
-
for (let i = 0; i < sets.length; i++) {
|
|
184
|
-
if (i === keep) continue
|
|
185
|
-
const title = String((body.titles && body.titles[i]) || `${sq.title} (${groups[i].join(', ')})`).slice(0, 200)
|
|
186
|
-
const ns = await store.openSquare({ square: { id: uuidv7(now()), quilt_id: q.id, title, line: sq.line, base: sq.base, holder: sq.holder, opened_by: me.member_id, writers: [], ...(sq.task_id ? { task_id: sq.task_id } : {}) }, at: now() })
|
|
187
|
-
const changes = sets[i].map((e) => toChange(e.path, e.after || null))
|
|
188
|
-
const made = m.makeCommit(q.id, ns, { parents: [sq.base].filter(Boolean), predecessor: sq.head, author: authorOf(me), onBehalfOf: me.on_behalf_of, message: `${title}\n\n(split from ${squareShort(sq.id)})`, changes })
|
|
189
|
-
await writeMade(q.id, made, null, me, [], { split_from: sq.id, reason: 'split' })
|
|
190
|
-
opened.push(Object.assign(ns, { group: i }))
|
|
191
|
-
}
|
|
192
|
-
try {
|
|
193
|
-
const head = await getCommit(q.id, sq.head)
|
|
194
|
-
const made = m.makeCommit(q.id, sq, { parents: [sq.base].filter(Boolean), predecessor: sq.head, author: authorOf(me), onBehalfOf: me.on_behalf_of, message: head.message, changes: kept.map((e) => toChange(e.path, e.after || null)) })
|
|
195
|
-
await writeMade(q.id, made, sq.head, me, [], { split_into: opened.map((s) => s.id) })
|
|
196
|
-
await move({ square_id: sq.id, op: 'split', actor: me.member_id, at: now(), patch: {}, log: { into: opened.map((s) => s.id), kept_paths: kept.length, blocks_moved: 0 } })
|
|
197
|
-
} catch (err) {
|
|
198
|
-
// The square moved under the split: the new squares go away again (abandoned), nothing else changed.
|
|
199
|
-
for (const s of opened) await move({ square_id: s.id, op: 'abandon', actor: me.member_id, at: now(), patch: { state: 'abandoned' }, log: { reason: 'split_failed' } }).catch(() => {})
|
|
200
|
-
throw err
|
|
201
|
-
}
|
|
202
|
-
return { ok: true, kept: kept.map((e) => e.path), square: await squareView(await store.getSquare(sq.id)), squares: await Promise.all(opened.map(async (s) => ({ ...(await squareView(await store.getSquare(s.id))), paths: sets[s.group].map((e) => e.path) }))) }
|
|
203
|
-
})
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* One square with 06's derived status: merged_under_you (patches since the base that changed a
|
|
208
|
-
* path the square changed, newest first, at most 50 plus `more`) and would_conflict (07's merge
|
|
209
|
-
* as a dry run against the tip: the paths that would carry a term, and the patch each meets).
|
|
210
|
-
*/
|
|
211
|
-
async function square (me, ref, sqRef) {
|
|
212
|
-
const q = await quiltFor(me, ref)
|
|
213
|
-
return statusOf(q.id, await squareByRef(q.id, sqRef))
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
async function statusOf (qid, sq) {
|
|
217
|
-
const q = { id: qid }
|
|
218
|
-
const view = await squareView(sq)
|
|
219
|
-
if (sq.state !== 'open' || !view.behind_by) return { ...view, merged_under_you: [], would_conflict: [] }
|
|
220
|
-
const ln = await store.getLine(q.id, sq.line)
|
|
221
|
-
const changed = sq.head ? (await reader(q.id).diff(await snapshot(q.id, sq.base), await snapshot(q.id, sq.head))).map((e) => e.path) : []
|
|
222
|
-
if (!changed.length) return { ...view, merged_under_you: [], would_conflict: [] }
|
|
223
|
-
const hit = await pathPatches(q.id, sq.line, changed.slice(0, 2000), ln.patch_count, sq.base, 50)
|
|
224
|
-
const merged = []
|
|
225
|
-
for (const id of hit.ids) {
|
|
226
|
-
const c = await getCommit(q.id, id)
|
|
227
|
-
const touched = c.changes.filter((ch) => changed.includes(ch.path)).map((ch) => ch.path)
|
|
228
|
-
merged.push({ patch_no: c.patch?.patch_no, commit: id, author: c.author.member_id, ...(c.task_id ? { task_id: c.task_id } : {}), message: String(c.message || '').split('\n')[0], paths: touched })
|
|
229
|
-
}
|
|
230
|
-
const dry = await m.rebaseCommit(q.id, sq, sq.head, ln.tip, {})
|
|
231
|
-
const wouldConflict = dry.conflicts.map((t) => {
|
|
232
|
-
const by = merged.find((p) => p.paths.includes(t.path))
|
|
233
|
-
return { path: t.path, kind: t.kind, ...(by ? { patch_no: by.patch_no, author: by.author } : {}) }
|
|
234
|
-
})
|
|
235
|
-
return { ...view, merged_under_you: merged, ...(hit.more ? { merged_under_you_more: true } : {}), would_conflict: wouldConflict }
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return { handoff, abandon, reopen, mergeSquare, fold, split, square, mergeBase, statusOf }
|
|
239
|
-
}
|