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/dogfood.js
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
// The dogfood round trip (week 4, item 2): a git repository's full history into a new quilt on
|
|
2
|
-
// whatever API the client points at, every imported commit's tree checked against git, then
|
|
3
|
-
// `main` exported back to git and checked: one exported commit per first-parent commit, each
|
|
4
|
-
// with git's tree and its `Quilt-Git-Sha` trailer naming that commit, and a second export from
|
|
5
|
-
// scratch giving the same tip (the export is deterministic).
|
|
6
|
-
import fs from 'node:fs'
|
|
7
|
-
import os from 'node:os'
|
|
8
|
-
import path from 'node:path'
|
|
9
|
-
import { execFileSync } from 'node:child_process'
|
|
10
|
-
import { importGit } from './import-git.js'
|
|
11
|
-
import { exportGit } from './export-git.js'
|
|
12
|
-
|
|
13
|
-
const git = (dir, args) => execFileSync('git', ['-C', dir, ...args], { maxBuffer: 1 << 30, env: { ...process.env, GIT_NO_REPLACE_OBJECTS: '1' } }).toString()
|
|
14
|
-
|
|
15
|
-
/** `git rev-list` with one format line per commit: [[sha, field], ...]. */
|
|
16
|
-
function revs (dir, args, format) {
|
|
17
|
-
const out = git(dir, ['rev-list', `--format=${format}`, ...args]).split('\n')
|
|
18
|
-
const rows = []
|
|
19
|
-
for (let i = 0; i < out.length; i++) {
|
|
20
|
-
if (!out[i].startsWith('commit ')) continue
|
|
21
|
-
rows.push([out[i].slice(7), out[i + 1] || ''])
|
|
22
|
-
}
|
|
23
|
-
return rows
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Runs the round trip. `repo` is any git repository on this machine, `rev` the commit whose
|
|
28
|
-
* history is imported (HEAD by default), `slug` the new quilt. Returns a report; throws on the
|
|
29
|
-
* first mismatch.
|
|
30
|
-
*/
|
|
31
|
-
export async function dogfood (client, { repo, rev = 'HEAD', slug, work, keep = false, batch = 200, log = () => {} }) {
|
|
32
|
-
const sha = git(repo, ['rev-parse', '--verify', `${rev}^{commit}`]).trim()
|
|
33
|
-
const dir = work || fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-dogfood-'))
|
|
34
|
-
const src = path.join(dir, 'src')
|
|
35
|
-
const times = {}
|
|
36
|
-
try {
|
|
37
|
-
// A bare-ish copy whose HEAD is `rev`, so the import walks exactly that history.
|
|
38
|
-
git(os.tmpdir(), ['clone', '-q', '--shared', '--no-checkout', repo, src])
|
|
39
|
-
git(src, ['update-ref', '--no-deref', 'HEAD', sha])
|
|
40
|
-
|
|
41
|
-
let t = Date.now()
|
|
42
|
-
log(`Importing ${sha.slice(0, 12)} of ${repo} into ${slug}…`)
|
|
43
|
-
const imp = await importGit(client, { repo: src, slug, batch, validate: 'all', log, onlyHead: true, fresh: true })
|
|
44
|
-
times.import_ms = Date.now() - t
|
|
45
|
-
log(`Imported ${imp.commits} commits (${imp.patches} patches); every commit's tree equals git's (${imp.validated}).`)
|
|
46
|
-
|
|
47
|
-
t = Date.now()
|
|
48
|
-
const out = path.join(dir, 'export.git')
|
|
49
|
-
const exp = await exportGit(client, { q: imp.quilt, dir: out, log })
|
|
50
|
-
times.export_ms = Date.now() - t
|
|
51
|
-
|
|
52
|
-
const want = revs(src, ['--first-parent', '--reverse', sha], '%T')
|
|
53
|
-
const got = revs(out, ['--reverse', 'refs/heads/main'], '%T')
|
|
54
|
-
if (got.length !== want.length) throw new Error(`export: main has ${got.length} commits, git's first-parent history ${want.length}`)
|
|
55
|
-
const trailers = revs(out, ['--reverse', 'refs/heads/main'], '%(trailers:key=Quilt-Git-Sha,valueonly,separator=)')
|
|
56
|
-
for (let i = 0; i < want.length; i++) {
|
|
57
|
-
if (got[i][1] !== want[i][1]) throw new Error(`export: commit ${i + 1} (git ${want[i][0].slice(0, 12)}) has tree ${got[i][1].slice(0, 12)}, git's is ${want[i][1].slice(0, 12)}`)
|
|
58
|
-
if (trailers[i][1].trim() !== want[i][0]) throw new Error(`export: commit ${i + 1} names git ${trailers[i][1].trim() || 'nothing'} in Quilt-Git-Sha, expected ${want[i][0]}`)
|
|
59
|
-
}
|
|
60
|
-
log(`Exported ${exp.exported} patches; every tree and Quilt-Git-Sha matches git's first-parent history.`)
|
|
61
|
-
|
|
62
|
-
const again = await exportGit(client, { q: imp.quilt, dir: path.join(dir, 'again.git'), page: 97 })
|
|
63
|
-
if (again.sha !== exp.sha) throw new Error(`export is not deterministic: ${exp.sha} then ${again.sha}`)
|
|
64
|
-
log(`Exported again from scratch: same tip ${exp.sha.slice(0, 12)}.`)
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
quilt: imp.quilt,
|
|
68
|
-
slug: imp.slug,
|
|
69
|
-
rev: sha,
|
|
70
|
-
commits: imp.commits,
|
|
71
|
-
patches: imp.patches,
|
|
72
|
-
validated: imp.validated,
|
|
73
|
-
exported: exp.exported,
|
|
74
|
-
export_sha: exp.sha,
|
|
75
|
-
left_out: imp.report,
|
|
76
|
-
...times,
|
|
77
|
-
...(keep ? { work: dir } : {})
|
|
78
|
-
}
|
|
79
|
-
} finally {
|
|
80
|
-
if (!keep && !work) fs.rmSync(dir, { recursive: true, force: true })
|
|
81
|
-
}
|
|
82
|
-
}
|
package/src/quilts/errors.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// What both the server and the seam client need without loading the server (service.js).
|
|
2
|
-
export class QuiltError extends Error {
|
|
3
|
-
/** `code` is the wire name (04/07/12): head_moved, tip_moved, conflict, not_found, ... */
|
|
4
|
-
constructor (status, code, message, extra = {}) { super(message); this.status = status; this.code = code; this.extra = extra }
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
/** A square's short handle: sq: + the last 8 hex of its uuid (02). */
|
|
8
|
-
export const squareShort = (id) => `sq:${String(id).replace(/-/g, '').slice(-8)}`
|
package/src/quilts/events.js
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
// Events (plans/quilt-source-control/12): never written in the commit or merge transaction. The
|
|
2
|
-
// events job reads the quilt's log after its cursor, derives each event and its recipients, and
|
|
3
|
-
// writes them with the cursor moved in one transaction (store.addEvents); then it POSTs each one
|
|
4
|
-
// to the recipients' webhooks, signed and retried by seam's own webhook.js. Members read theirs
|
|
5
|
-
// over HTTP (GET /v1/quilts/:q/events?after=), the CLI (`seam events`) and the MCP tool seam_events, so an agent in any tool is woken the same way.
|
|
6
|
-
import { QuiltError, squareShort } from './service.js'
|
|
7
|
-
import { makeSubscription, deliverWebhook, RETRY_DELAYS_MS } from './webhook.js'
|
|
8
|
-
|
|
9
|
-
export const QUILT_EVENT_KINDS = ['patch.merged', 'patch.reverted', 'conflict.added', 'square.handoff', 'check.requested', 'check.cancelled']
|
|
10
|
-
const MAX_DIFF_PATHS = 200
|
|
11
|
-
|
|
12
|
-
export function eventOps (svc, sq, { allowLocalWebhooks = false, fetch, delays = RETRY_DELAYS_MS, log = () => {} } = {}) {
|
|
13
|
-
const { store, now, quiltFor, getCommit, getTerm, conflictsAt, snapshot, reader } = svc.internals
|
|
14
|
-
const running = new Map() // quilt → the job in flight, so one process runs one job per quilt at a time
|
|
15
|
-
const sending = new Map() // quilt + member → delivery chain, so a member's POSTs keep their order
|
|
16
|
-
|
|
17
|
-
const by = (c) => ({ member_id: c.author.member_id, kind: c.author.kind, name: c.author.name, ...(c.author.tool ? { tool: c.author.tool } : {}) })
|
|
18
|
-
|
|
19
|
-
async function changedPaths (qid, s) {
|
|
20
|
-
if (!s.head) return []
|
|
21
|
-
return (await reader(qid).diff(await snapshot(qid, s.base), await snapshot(qid, s.head))).map((e) => e.path)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** The events one log row causes. */
|
|
25
|
-
async function derive (qid, row) {
|
|
26
|
-
const out = []
|
|
27
|
-
// check.requested (13, checks.js): to the runners a line's policy names.
|
|
28
|
-
if (svc.internals.checkRequests) out.push(...await svc.internals.checkRequests(qid, row))
|
|
29
|
-
if (row.kind === 'merge' && row.payload?.reverts) {
|
|
30
|
-
// patch.reverted: the reverted patch's holder (and whom they acted for) learns it is gone (07 step 4).
|
|
31
|
-
const c = await getCommit(qid, row.commit)
|
|
32
|
-
const gone = await store.patch(qid, row.line, Number(row.payload.reverts))
|
|
33
|
-
const orig = gone && gone.square ? await store.getSquare(gone.square) : null
|
|
34
|
-
const oc = gone ? await getCommit(qid, gone.commit).catch(() => null) : null
|
|
35
|
-
const to = [orig && !orig.import ? orig.holder : null, oc && oc.on_behalf_of].filter(Boolean)
|
|
36
|
-
if (to.length) {
|
|
37
|
-
out.push({
|
|
38
|
-
kind: 'patch.reverted', at: row.at, cause_seq: row.seq, square: orig.id, line: row.line, patch_no: Number(row.payload.reverts), by: by(c), paths: c.changes.map((ch) => ch.path).slice(0, MAX_DIFF_PATHS),
|
|
39
|
-
payload: { patch_no: Number(row.payload.reverts), revert_patch_no: row.patch_no, commit: c.id, message: c.message },
|
|
40
|
-
recipients: [...new Set(to)]
|
|
41
|
-
})
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
if (row.kind === 'merge') {
|
|
45
|
-
// patch.merged: to the holder and writers of every other open square on the line that changed a path the patch changed.
|
|
46
|
-
const c = await getCommit(qid, row.commit)
|
|
47
|
-
const touched = new Set(c.changes.map((ch) => ch.path))
|
|
48
|
-
for (const s of await store.squares(qid, { state: 'open' })) {
|
|
49
|
-
if (s.id === row.square || s.line !== row.line) continue
|
|
50
|
-
const overlap = (await changedPaths(qid, s)).filter((p) => touched.has(p))
|
|
51
|
-
if (!overlap.length) continue
|
|
52
|
-
const st = await sq.statusOf(qid, s)
|
|
53
|
-
const wc = (st.would_conflict || []).map((w) => w.path)
|
|
54
|
-
out.push({
|
|
55
|
-
kind: 'patch.merged', at: row.at, cause_seq: row.seq, square: s.id, line: row.line, patch_no: row.patch_no, by: by(c), paths: overlap,
|
|
56
|
-
...(c.task_id ? { task: { id: c.task_id } } : {}),
|
|
57
|
-
payload: { patch_no: row.patch_no, commit: c.id, message: c.message, author: c.author.member_id, ...(c.task_id ? { task: c.task_id } : {}), stat: c.changes.slice(0, MAX_DIFF_PATHS).map((ch) => ({ path: ch.path, op: ch.op })), would_conflict: wc.length > 0, would_conflict_paths: wc },
|
|
58
|
-
recipients: [s.holder, ...(s.writers || [])]
|
|
59
|
-
})
|
|
60
|
-
}
|
|
61
|
-
} else if (row.kind === 'commit') {
|
|
62
|
-
// conflict.added: a term at a path that did not hold that term before this commit.
|
|
63
|
-
const c = await getCommit(qid, row.commit)
|
|
64
|
-
const terms = c.changes.filter((ch) => ch.op === 'conflict')
|
|
65
|
-
if (!terms.length) return out
|
|
66
|
-
const prev = c.predecessor || c.parents[0]
|
|
67
|
-
const before = new Map(prev ? (await conflictsAt(qid, prev)).map((e) => [e.path, e.term]) : [])
|
|
68
|
-
const s = await store.getSquare(row.square)
|
|
69
|
-
const cause = row.payload?.rebased_onto ? { commit: row.payload.rebased_onto, ...((await getCommit(qid, row.payload.rebased_onto).catch(() => null))?.patch || {}) } : row.payload?.merged_from ? { commit: row.payload.merged_from } : row.payload?.folded_from ? { square: row.payload.folded_from } : null
|
|
70
|
-
for (const ch of terms) {
|
|
71
|
-
if (before.get(ch.path) === ch.term) continue
|
|
72
|
-
const t = await getTerm(qid, ch.term)
|
|
73
|
-
out.push({
|
|
74
|
-
kind: 'conflict.added', at: row.at, cause_seq: row.seq, square: row.square, line: s && s.line, by: by(c), paths: [ch.path],
|
|
75
|
-
payload: { path: ch.path, kind: t.kind, term: ch.term, commit: c.id, sides: t.adds.map((a) => ({ by: a.by || null, commit: a.from_commit || null, ...(a.from_square ? { square: squareShort(a.from_square) } : {}) })), ...(cause ? { caused_by: cause } : {}), resolver: s ? s.holder : null },
|
|
76
|
-
recipients: s ? [s.holder] : []
|
|
77
|
-
})
|
|
78
|
-
}
|
|
79
|
-
} else if (row.kind === 'square' && row.payload?.op === 'handoff') {
|
|
80
|
-
out.push({
|
|
81
|
-
kind: 'square.handoff', at: row.at, cause_seq: row.seq, square: row.square, by: { member_id: row.actor },
|
|
82
|
-
payload: { from: row.payload.from, to: row.payload.to, ...(row.payload.context ? { note: row.payload.context } : {}) },
|
|
83
|
-
recipients: [row.payload.from, row.payload.to]
|
|
84
|
-
})
|
|
85
|
-
}
|
|
86
|
-
return out
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Runs the events job for a quilt until it has read the whole log; returns the new events. */
|
|
90
|
-
function pump (qid) {
|
|
91
|
-
const prev = running.get(qid) || Promise.resolve()
|
|
92
|
-
const job = prev.catch(() => {}).then(async () => {
|
|
93
|
-
const made = []
|
|
94
|
-
for (;;) {
|
|
95
|
-
const cursor = await store.eventCursor(qid)
|
|
96
|
-
const rows = await store.logRows(qid, { after: cursor, limit: 200 })
|
|
97
|
-
if (!rows.length) return made
|
|
98
|
-
let events = []
|
|
99
|
-
for (const r of rows) events.push(...await derive(qid, r))
|
|
100
|
-
// A new check request cancels what it supersedes (checks.js settleRuns).
|
|
101
|
-
let cancels = []
|
|
102
|
-
if (svc.internals.settleRuns) ({ events, cancels } = await svc.internals.settleRuns(qid, events))
|
|
103
|
-
// A check.requested event's run record is stored first (idempotent on run_key), so a
|
|
104
|
-
// runner polling GET /checks finds every run it is woken for.
|
|
105
|
-
const runs = events.filter((e) => e.run).map((e) => e.run)
|
|
106
|
-
if (runs.length) await store.addCheckRuns({ quilt_id: qid, runs })
|
|
107
|
-
for (const e of events) delete e.run
|
|
108
|
-
try {
|
|
109
|
-
made.push(...await store.addEvents({ quilt_id: qid, events, expect_cursor: cursor, cursor: rows.at(-1).seq }))
|
|
110
|
-
if (cancels.length) await svc.internals.applyCancels(qid, cancels)
|
|
111
|
-
} catch (err) { if (err.code !== 'cursor_moved') throw err }
|
|
112
|
-
}
|
|
113
|
-
})
|
|
114
|
-
running.set(qid, job)
|
|
115
|
-
job.finally(() => { if (running.get(qid) === job) running.delete(qid) }).catch(() => {})
|
|
116
|
-
// Deliveries run beside the job (a dead webhook's retries never hold up reading events).
|
|
117
|
-
return job.then((made) => { const d = deliver(qid, made); inflight.add(d); d.finally(() => inflight.delete(d)); return made })
|
|
118
|
-
}
|
|
119
|
-
const inflight = new Set()
|
|
120
|
-
/** Waits for every webhook POST in flight (tests, and a clean shutdown). */
|
|
121
|
-
const eventsSettled = async () => { while (inflight.size) await Promise.all([...inflight]) }
|
|
122
|
-
|
|
123
|
-
/** The event as a member sees it (12's shape; recipients are not shown). */
|
|
124
|
-
const view = (e) => {
|
|
125
|
-
const { recipients, quilt_id: q, ...rest } = e
|
|
126
|
-
void recipients
|
|
127
|
-
return { ...rest, quilt: q, ...(e.square ? { square_short: squareShort(e.square) } : {}) }
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** POSTs new events to every recipient that subscribed (one chain per member, in order). */
|
|
131
|
-
async function deliver (qid, events) {
|
|
132
|
-
const waits = []
|
|
133
|
-
for (const e of events) {
|
|
134
|
-
for (const m of new Set(e.recipients || [])) {
|
|
135
|
-
const sub = await store.webhook(qid, m)
|
|
136
|
-
if (!sub || !sub.events.includes(e.kind)) continue
|
|
137
|
-
const key = `${qid}\u0000${m}`
|
|
138
|
-
const payload = { event: e.kind, id: `${qid}:${e.event_seq}`, to: m, ts: e.at || now(), ...view(e) }
|
|
139
|
-
const next = (sending.get(key) || Promise.resolve()).then(() => deliverWebhook(sub, payload, { delays, log, ...(fetch ? { fetch } : {}) })).catch(() => {})
|
|
140
|
-
sending.set(key, next)
|
|
141
|
-
waits.push(next)
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
await Promise.all(waits)
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** GET /events: the caller's events after `after` (the job runs first, so none is missing). */
|
|
148
|
-
async function events (me, ref, { after = 0, kinds, limit = 100 } = {}) {
|
|
149
|
-
const q = await quiltFor(me, ref)
|
|
150
|
-
await pump(q.id)
|
|
151
|
-
const list = await store.eventsFor(q.id, me.member_id, { after: Number(after) || 0, kinds: kinds && kinds.length ? kinds : undefined, limit: Math.min(Number(limit) || 100, 1000) })
|
|
152
|
-
return { events: list.map(view), after: list.length ? list.at(-1).event_seq : Number(after) || 0 }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** POST /webhook: { url, secret?, events?, bearer? } subscribes the caller; { url: null } removes it. */
|
|
156
|
-
async function subscribe (me, ref, body = {}) {
|
|
157
|
-
const q = await quiltFor(me, ref)
|
|
158
|
-
if (body.url === null || body.off) { await store.setWebhook({ quilt_id: q.id, member_id: me.member_id, sub: null }); return { ok: true, removed: true } }
|
|
159
|
-
const kinds = body.events == null ? QUILT_EVENT_KINDS.slice() : body.events
|
|
160
|
-
if (!Array.isArray(kinds) || !kinds.length || kinds.some((k) => !QUILT_EVENT_KINDS.includes(k))) throw new QuiltError(400, 'bad_request', `events: give some of ${QUILT_EVENT_KINDS.join(', ')}`)
|
|
161
|
-
let sub
|
|
162
|
-
try { sub = makeSubscription({ url: body.url, secret: body.secret, bearer: body.bearer }, { allowLocal: allowLocalWebhooks }) } catch (err) { throw new QuiltError(400, 'bad_request', err.message) }
|
|
163
|
-
const row = { url: sub.url, secret: sub.secret, events: [...new Set(kinds)], since: sub.since, ...(sub.bearer ? { bearer: sub.bearer } : {}) }
|
|
164
|
-
// Events already in the table are not news to a new subscription: the job runs first.
|
|
165
|
-
await pump(q.id)
|
|
166
|
-
await store.setWebhook({ quilt_id: q.id, member_id: me.member_id, sub: row })
|
|
167
|
-
return { ok: true, url: row.url, events: row.events, ...(sub.made ? { secret: row.secret } : {}) }
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return { events, subscribe, pumpEvents: pump, eventsSettled }
|
|
171
|
-
}
|
package/src/quilts/export-git.js
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
// The local driver of the one-way git export (plans/quilt-source-control/14, Export): reads a
|
|
2
|
-
// line's patches from the server (GET /quilts/:q/export/git) and writes each as one commit on a
|
|
3
|
-
// branch of a plain git repository with `git fast-import`, so CI and deploys that expect git
|
|
4
|
-
// keep working. The commit fields come from the server whole, so the same patch always gives
|
|
5
|
-
// the same commit id, on any machine, in any number of runs. A run picks up after the last
|
|
6
|
-
// patch it exported (state in the repository's `quilt-export.json`), and refuses a branch that
|
|
7
|
-
// moved under it: the export is one way, never a merge.
|
|
8
|
-
import { spawn, execFileSync } from 'node:child_process'
|
|
9
|
-
import fs from 'node:fs'
|
|
10
|
-
import path from 'node:path'
|
|
11
|
-
|
|
12
|
-
const MODES = { file: '100644', exec: '100755', symlink: '120000', gitlink: '160000' }
|
|
13
|
-
const GIT_ENV = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_CONFIG_NOSYSTEM: '1' }
|
|
14
|
-
|
|
15
|
-
const git = (dir, args) => execFileSync('git', ['-C', dir, ...args], { env: GIT_ENV, maxBuffer: 256 * 1024 * 1024 }).toString().trim()
|
|
16
|
-
const tryGit = (dir, args) => { try { return git(dir, args) } catch { return null } }
|
|
17
|
-
|
|
18
|
-
/** A path as fast-import reads it: C-quoted when it would otherwise be misread. */
|
|
19
|
-
const fiPath = (p) => (/^"|[\n"\\]/.test(p) ? '"' + p.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"' : p)
|
|
20
|
-
const ident = (who) => `${who.name} <${who.email}> ${who.at} +0000`
|
|
21
|
-
|
|
22
|
-
/** The repository at `dir`, made bare when it does not exist yet: its git dir. */
|
|
23
|
-
function openRepo (dir) {
|
|
24
|
-
if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); git(dir, ['init', '-q', '--bare']) }
|
|
25
|
-
const gd = tryGit(dir, ['rev-parse', '--absolute-git-dir'])
|
|
26
|
-
if (!gd) throw new Error(`${dir} is not a git repository`)
|
|
27
|
-
return gd
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** fast-import as a writable stream with backpressure; done() → its exit. */
|
|
31
|
-
function fastImport (dir, marks) {
|
|
32
|
-
const p = spawn('git', ['-C', dir, 'fast-import', '--quiet', '--date-format=raw', `--export-marks=${marks}`], { env: GIT_ENV, stdio: ['pipe', 'ignore', 'pipe'] })
|
|
33
|
-
let err = ''
|
|
34
|
-
p.stderr.on('data', (d) => { err += d })
|
|
35
|
-
const exit = new Promise((resolve, reject) => p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`git fast-import failed: ${err.trim()}`)))))
|
|
36
|
-
exit.catch(() => {})
|
|
37
|
-
return {
|
|
38
|
-
write (chunk) { return p.stdin.write(chunk) ? null : new Promise((resolve) => p.stdin.once('drain', resolve)) },
|
|
39
|
-
async done () { p.stdin.end(); await exit }
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Every blob in `ids`, as Buffers, in as many requests as the server's size cap needs. */
|
|
44
|
-
async function fetchBlobs (client, q, ids) {
|
|
45
|
-
const out = new Map()
|
|
46
|
-
let todo = [...new Set(ids)]
|
|
47
|
-
while (todo.length) {
|
|
48
|
-
const r = await client.blobs(q, todo.slice(0, 2000))
|
|
49
|
-
const got = Object.entries(r.blobs || {})
|
|
50
|
-
if (!got.length) throw new Error(`the server returned none of ${todo.length} blobs`)
|
|
51
|
-
for (const [id, b64] of got) out.set(id, Buffer.from(b64, 'base64'))
|
|
52
|
-
todo = todo.filter((id) => !out.has(id))
|
|
53
|
-
}
|
|
54
|
-
return out
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Exports `line` of quilt `q` to `branch` of the repository at `dir` (created bare when
|
|
59
|
-
* missing). Returns { exported, patch_no, sha, branch }.
|
|
60
|
-
*/
|
|
61
|
-
export async function exportGit (client, { q, dir, line = 'main', branch, page = 200, log = () => {} }) {
|
|
62
|
-
branch = branch || line
|
|
63
|
-
const gitDir = openRepo(dir)
|
|
64
|
-
const stateFile = path.join(gitDir, 'quilt-export.json')
|
|
65
|
-
const shaFile = path.join(gitDir, 'quilt-export-shas')
|
|
66
|
-
const all = fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, 'utf8')) : {}
|
|
67
|
-
const key = `${line}→${branch}`
|
|
68
|
-
const state = all[key] || null
|
|
69
|
-
const tip = tryGit(dir, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`])
|
|
70
|
-
if (state && state.quilt !== undefined && state.quilt !== null) {
|
|
71
|
-
const info = await client.quilt(q)
|
|
72
|
-
if (info.id !== state.quilt) throw new Error(`${dir} holds the export of another quilt (${state.quilt})`)
|
|
73
|
-
}
|
|
74
|
-
if (state ? tip !== state.sha : tip) throw new Error(`branch ${branch} in ${dir} moved since the last export (${tip || 'gone'}, expected ${state ? state.sha : 'no branch'}): the export never merges or forces; point it at a new branch or repository`)
|
|
75
|
-
|
|
76
|
-
let after = state ? state.patch_no : -1
|
|
77
|
-
let sha = state ? state.sha : null
|
|
78
|
-
let exported = 0
|
|
79
|
-
let version = state ? state.version : null
|
|
80
|
-
let quiltId = state ? state.quilt : null
|
|
81
|
-
for (;;) {
|
|
82
|
-
const r = await client.exportPatches(q, { line, after, limit: page })
|
|
83
|
-
if (version !== null && version !== r.version) throw new Error(`export format ${r.version} differs from the ${version} this branch was exported with: export to a new branch`)
|
|
84
|
-
version = r.version; quiltId = r.quilt
|
|
85
|
-
if (!r.patches.length) break
|
|
86
|
-
const blobs = await fetchBlobs(client, q, r.patches.flatMap((p) => p.changes.filter((c) => c.op === 'put').map((c) => c.blob)))
|
|
87
|
-
const marksFile = path.join(gitDir, 'quilt-export.marks')
|
|
88
|
-
const fi = fastImport(dir, marksFile)
|
|
89
|
-
const send = async (s) => { const w = fi.write(s); if (w) await w }
|
|
90
|
-
let mark = 0
|
|
91
|
-
const blobMark = new Map()
|
|
92
|
-
for (const [id, bytes] of blobs) {
|
|
93
|
-
// A gitlink's content is the submodule commit it pins; git writes no blob for it.
|
|
94
|
-
if (r.patches.some((p) => p.changes.some((c) => c.blob === id && c.mode === 'gitlink'))) blobMark.set(id, bytes.toString('utf8').trim())
|
|
95
|
-
if (blobMark.has(id)) continue
|
|
96
|
-
blobMark.set(id, ++mark)
|
|
97
|
-
await send(`blob\nmark :${mark}\ndata ${bytes.length}\n`); await send(bytes); await send('\n')
|
|
98
|
-
}
|
|
99
|
-
const commitMark = new Map()
|
|
100
|
-
let first = true
|
|
101
|
-
for (const p of r.patches) {
|
|
102
|
-
commitMark.set(p.patch_no, ++mark)
|
|
103
|
-
const msg = Buffer.from(p.message, 'utf8')
|
|
104
|
-
let s = `commit refs/heads/${branch}\nmark :${mark}\nauthor ${ident(p.author)}\ncommitter ${ident(p.committer)}\ndata ${msg.length}\n`
|
|
105
|
-
await send(s); await send(msg); s = '\n'
|
|
106
|
-
if (first && sha) s += `from ${sha}\n`
|
|
107
|
-
first = false
|
|
108
|
-
for (const c of p.changes) {
|
|
109
|
-
if (c.op === 'delete') s += `D ${fiPath(c.path)}\n`
|
|
110
|
-
else if (c.mode === 'gitlink') s += `M 160000 ${blobMark.get(c.blob)} ${fiPath(c.path)}\n`
|
|
111
|
-
else s += `M ${MODES[c.mode || 'file'] || MODES.file} :${blobMark.get(c.blob)} ${fiPath(c.path)}\n`
|
|
112
|
-
}
|
|
113
|
-
await send(s + '\n')
|
|
114
|
-
}
|
|
115
|
-
await fi.done()
|
|
116
|
-
const marks = new Map(fs.readFileSync(marksFile, 'utf8').split('\n').filter(Boolean).map((l) => { const [m, h] = l.split(' '); return [Number(m.slice(1)), h] }))
|
|
117
|
-
fs.rmSync(marksFile, { force: true })
|
|
118
|
-
const lines = r.patches.map((p) => `${line} ${p.patch_no} ${marks.get(commitMark.get(p.patch_no))}`)
|
|
119
|
-
fs.appendFileSync(shaFile, lines.join('\n') + '\n')
|
|
120
|
-
const last = r.patches[r.patches.length - 1]
|
|
121
|
-
after = last.patch_no
|
|
122
|
-
sha = marks.get(commitMark.get(last.patch_no))
|
|
123
|
-
exported += r.patches.length
|
|
124
|
-
all[key] = { quilt: quiltId, line, branch, patch_no: after, sha, version }
|
|
125
|
-
fs.writeFileSync(stateFile, JSON.stringify(all, null, 2) + '\n')
|
|
126
|
-
log(` ${line}/${after} → ${sha.slice(0, 12)} (${exported} exported)`)
|
|
127
|
-
}
|
|
128
|
-
if (sha && !tryGit(dir, ['symbolic-ref', '-q', 'HEAD'])?.endsWith(`/${branch}`) && !tryGit(dir, ['rev-parse', '--verify', '-q', 'HEAD'])) git(dir, ['symbolic-ref', 'HEAD', `refs/heads/${branch}`])
|
|
129
|
-
return { exported, patch_no: after, sha, branch }
|
|
130
|
-
}
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
// Export to a git remote (plans/quilt-source-control/14, "Export"): `settings.export` names a
|
|
2
|
-
// remote (any: GitHub, GitLab, a bare repository), a credential the owner gives by name, the
|
|
3
|
-
// lines to export (names, or `release/*`) and whether releases go out as tags. After each merge
|
|
4
|
-
// on those lines the server writes the new patches as 14's commits (export.js and export-git.js:
|
|
5
|
-
// deterministic, so the same patch gives the same sha on any machine) in a bare repository in
|
|
6
|
-
// scratch and pushes them, fast-forward only: a remote that moved is refused, never forced, and
|
|
7
|
-
// the status says which line and sha. Failures to reach the remote retry with backoff. A
|
|
8
|
-
// quilt that an import made continues git's own history when the target is the import's source:
|
|
9
|
-
// the first exported patch's parent is the last imported sha (14, cut-over). While its import
|
|
10
|
-
// follows git, the export may go only to a scratch remote that is not the source.
|
|
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 { QuiltError } from './service.js'
|
|
16
|
-
import { exportGit } from './export-git.js'
|
|
17
|
-
import { remoteUrl, run, authArgs, localClient } from './import-follow.js'
|
|
18
|
-
|
|
19
|
-
const LEASE = ' export'
|
|
20
|
-
const matches = (patterns, line) => patterns.some((p) => p === line || (p.endsWith('/*') && line.startsWith(p.slice(0, -1))))
|
|
21
|
-
const BACKOFF = [30_000, 60_000, 5 * 60_000, 15 * 60_000, 60 * 60_000]
|
|
22
|
-
|
|
23
|
-
export function exportRemoteOps (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 = `exporter:${process.pid}:${crypto.randomBytes(4).toString('hex')}` } = {}) {
|
|
24
|
-
const { store, now, quiltFor, fromStore } = svc.internals
|
|
25
|
-
const running = new Map()
|
|
26
|
-
const again = new Set() // a write came in while a pass ran: run once more after it
|
|
27
|
-
const retryAt = new Map() // quilt id → when to try again (this server)
|
|
28
|
-
|
|
29
|
-
const save = async (qid, rec, actor, logged = false) => { try { return await store.setExport({ quilt_id: qid, export: rec, logged, actor, at: now() }) } catch (err) { throw fromStore(err) } }
|
|
30
|
-
|
|
31
|
-
/** PUT /v1/quilts/:q/export { target, credential?, lines?, releases?, scratch?, enabled? } (admin). */
|
|
32
|
-
async function setExport (me, ref, { target, credential = null, lines = ['main'], releases = false, scratch: isScratch = false, enabled = true } = {}) {
|
|
33
|
-
const q = await quiltFor(me, ref, 'admin')
|
|
34
|
-
const url = remoteUrl(target, { allowLocal })
|
|
35
|
-
const list = (Array.isArray(lines) ? lines : [lines]).map(String)
|
|
36
|
-
if (!list.length || list.some((l) => !/^[A-Za-z0-9._-][A-Za-z0-9._/-]{0,99}(\/\*)?$/.test(l))) throw new QuiltError(400, 'bad_request', 'lines: line names, or a prefix such as release/*')
|
|
37
|
-
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')
|
|
38
|
-
const imp = q.settings?.import
|
|
39
|
-
if (imp && imp.follow && ['reading', 'following'].includes(imp.state)) {
|
|
40
|
-
// 14: never push to the source while git is the authority, and only to a remote nothing pins.
|
|
41
|
-
if (url === imp.source?.url) throw new QuiltError(409, 'export_is_source', `${url} is the source this quilt's import follows; export to a scratch remote until cut-over`)
|
|
42
|
-
if (!isScratch) throw new QuiltError(409, 'scratch_required', 'while the import follows git, the export goes only to a scratch remote: say scratch: true (--scratch) to state that nothing pins a sha from it')
|
|
43
|
-
}
|
|
44
|
-
const prev = q.settings?.export || null
|
|
45
|
-
const rec = { target: url, ...(credential ? { credential: String(credential) } : {}), lines: list, releases: !!releases, scratch: !!isScratch, enabled: !!enabled, by: { member_id: me.member_id, kind: me.kind || 'person', name: me.name || null }, status: prev && prev.target === url ? prev.status : { state: 'idle', lines: {} } }
|
|
46
|
-
await save(q.id, rec, me.member_id, true)
|
|
47
|
-
if (rec.enabled) kick(q.id)
|
|
48
|
-
return view(rec)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const view = (rec) => rec
|
|
52
|
-
async function exportStatus (me, ref) {
|
|
53
|
-
const q = await quiltFor(me, ref, 'read')
|
|
54
|
-
if (!q.settings?.export) throw new QuiltError(404, 'not_found', 'this quilt exports nowhere')
|
|
55
|
-
return view(q.settings.export)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** One pass: every exported line's new patches, pushed; then the release tags. */
|
|
59
|
-
async function pass (qid) {
|
|
60
|
-
const q = await store.getQuilt(qid)
|
|
61
|
-
const rec = q?.settings?.export
|
|
62
|
-
if (!rec || !rec.enabled) return
|
|
63
|
-
const lease = await store.takeLease({ kind: 'line', quilt_id: qid, line: LEASE, holder, ttl_ms: 30 * 60_000, at: now() })
|
|
64
|
-
if (!lease.held) { retryAt.set(qid, now() + 30_000); return }
|
|
65
|
-
const status = { ...(rec.status || {}), lines: { ...(rec.status?.lines || {}) } }
|
|
66
|
-
try {
|
|
67
|
-
const dir = path.join(scratch, `export-${qid}.git`)
|
|
68
|
-
if (!fs.existsSync(path.join(dir, 'HEAD'))) { fs.mkdirSync(dir, { recursive: true }); await run(['init', '-q', '--bare', dir]) }
|
|
69
|
-
const auth = authArgs(rec.credential ? await secret(rec.credential, { quilt_id: qid }) : null)
|
|
70
|
-
const env = { GIT_ALLOW_PROTOCOL: allowLocal ? 'https:http:ssh:git:file' : 'https:http:ssh:git' }
|
|
71
|
-
const client = localClient(api, rec.by)
|
|
72
|
-
const imp = q.settings?.import
|
|
73
|
-
const stateFile = path.join(dir, 'quilt-export.json')
|
|
74
|
-
for (const ln of await store.lines(qid)) {
|
|
75
|
-
if (!matches(rec.lines, ln.name) || !ln.tip) continue
|
|
76
|
-
// What the remote has now; the export only ever adds to it.
|
|
77
|
-
const remoteRef = `refs/seam-remote/${ln.name}`
|
|
78
|
-
await run([...auth, '-C', dir, 'fetch', '--quiet', '--no-tags', rec.target, `+refs/heads/${ln.name}:${remoteRef}`], { env }).catch(() => run(['-C', dir, 'update-ref', '-d', remoteRef]).catch(() => {}))
|
|
79
|
-
const remoteSha = (await run(['-C', dir, 'rev-parse', '--verify', '-q', remoteRef]).catch(() => '')).trim() || null
|
|
80
|
-
const all = fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, 'utf8')) : {}
|
|
81
|
-
const key = `${ln.name}→${ln.name}`
|
|
82
|
-
// An imported quilt exporting to its source continues git's history from the import's tip.
|
|
83
|
-
const base = imp && imp.source?.url === rec.target ? (imp.lines || []).find((l) => l.line === ln.name) : null
|
|
84
|
-
if (!all[key] && base?.tip_sha && remoteSha === base.tip_sha) {
|
|
85
|
-
await run(['-C', dir, 'update-ref', `refs/heads/${ln.name}`, base.tip_sha])
|
|
86
|
-
all[key] = { quilt: qid, line: ln.name, branch: ln.name, patch_no: base.patches, sha: base.tip_sha, version: null }
|
|
87
|
-
fs.writeFileSync(stateFile, JSON.stringify(all, null, 2) + '\n')
|
|
88
|
-
}
|
|
89
|
-
const r = await exportGit(client, { q: qid, dir, line: ln.name })
|
|
90
|
-
if (remoteSha && remoteSha !== r.sha) {
|
|
91
|
-
// Is the remote behind us (fine: fast-forward) or somewhere else (refused)?
|
|
92
|
-
const behind = await run(['-C', dir, 'merge-base', '--is-ancestor', remoteSha, `refs/heads/${ln.name}`]).then(() => true, () => false)
|
|
93
|
-
if (!behind) throw Object.assign(new Error(`the remote's ${ln.name} moved to ${remoteSha.slice(0, 12)}, which the export did not write: it never forces (bring the commit in with a square, or reset the remote's branch)`), { refused: true, line: ln.name, sha: remoteSha })
|
|
94
|
-
}
|
|
95
|
-
if (r.sha && r.sha !== remoteSha) await run([...auth, '-C', dir, 'push', '--quiet', rec.target, `refs/heads/${ln.name}:refs/heads/${ln.name}`], { env })
|
|
96
|
-
status.lines[ln.name] = { patch_no: r.patch_no, sha: r.sha }
|
|
97
|
-
}
|
|
98
|
-
if (rec.releases) await pushTags(qid, dir, rec, auth, env)
|
|
99
|
-
Object.assign(status, { state: 'ok', at: now(), attempts: 0 })
|
|
100
|
-
delete status.error; delete status.next_at
|
|
101
|
-
retryAt.delete(qid)
|
|
102
|
-
} catch (err) {
|
|
103
|
-
const attempts = (status.attempts || 0) + 1
|
|
104
|
-
if (err.refused) Object.assign(status, { state: 'refused', error: err.message, line: err.line, remote_sha: err.sha, at: now() })
|
|
105
|
-
else {
|
|
106
|
-
const wait = BACKOFF[Math.min(attempts - 1, BACKOFF.length - 1)]
|
|
107
|
-
Object.assign(status, { state: 'retrying', error: String(err.message || err), attempts, next_at: now() + wait, at: now() })
|
|
108
|
-
retryAt.set(qid, now() + wait)
|
|
109
|
-
}
|
|
110
|
-
} finally {
|
|
111
|
-
const cur = (await store.getQuilt(qid))?.settings?.export
|
|
112
|
-
if (cur && cur.target === rec.target) await save(qid, { ...cur, status }, rec.by.member_id, cur.status?.state !== status.state).catch(() => {})
|
|
113
|
-
await store.giveLease({ kind: 'line', quilt_id: qid, line: LEASE, holder }).catch(() => {})
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Tags on exported commits: a release as an annotated tag with its notes, a tag as a plain one. Never moved. */
|
|
118
|
-
async function pushTags (qid, dir, rec, auth, env) {
|
|
119
|
-
const shaFile = path.join(dir, 'quilt-export-shas')
|
|
120
|
-
const shas = new Map()
|
|
121
|
-
if (fs.existsSync(shaFile)) for (const l of fs.readFileSync(shaFile, 'utf8').split('\n')) { const [line, n, sha] = l.split(' '); if (sha) shas.set(`${line}/${n}`, sha) }
|
|
122
|
-
const tags = await store.tags(qid)
|
|
123
|
-
for (const t of tags) {
|
|
124
|
-
const c = t.commit ? await store.getCommit(qid, t.commit) : null
|
|
125
|
-
const sha = c?.patch ? shas.get(`${c.patch.line}/${c.patch.patch_no}`) : null
|
|
126
|
-
if (!sha) continue
|
|
127
|
-
const have = (await run(['-C', dir, 'rev-parse', '--verify', '-q', `refs/tags/${t.name}`]).catch(() => '')).trim()
|
|
128
|
-
if (!have) {
|
|
129
|
-
if (t.release) {
|
|
130
|
-
// Deterministic: the tagger and date are the release's, not the clock's.
|
|
131
|
-
const at = Math.floor(Number(t.at || 0) / 1000)
|
|
132
|
-
await run(['-C', dir, '-c', 'user.name=Quilt', '-c', 'user.email=export@heyquilt.com', 'tag', '-a', t.name, sha, '-m', t.notes || t.name], { env: { GIT_COMMITTER_DATE: `${at} +0000` } })
|
|
133
|
-
} else await run(['-C', dir, 'tag', t.name, sha])
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
if (tags.length) await run([...auth, '-C', dir, 'push', '--quiet', rec.target, 'refs/tags/*:refs/tags/*'], { env })
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function kick (qid) {
|
|
140
|
-
if (running.has(qid)) { again.add(qid); return running.get(qid) }
|
|
141
|
-
const p = pass(qid).catch(() => {}).finally(() => { running.delete(qid); if (again.delete(qid)) kick(qid) })
|
|
142
|
-
running.set(qid, p)
|
|
143
|
-
return p
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** After a write: export when the quilt exports. */
|
|
147
|
-
async function afterWrite (qid) {
|
|
148
|
-
const q = await store.getQuilt(qid)
|
|
149
|
-
if (q?.settings?.export?.enabled) kick(qid)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function exportIdle (qid) { while (running.has(qid)) await running.get(qid) }
|
|
153
|
-
async function exportTick () { for (const [qid, at] of retryAt) if (now() >= at) kick(qid) }
|
|
154
|
-
|
|
155
|
-
return { setExport, exportStatus, exportIdle, exportTick, exportAfterWrite: afterWrite, exportPass: kick }
|
|
156
|
-
}
|
package/src/quilts/export.js
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
// The server half of the one-way git export (plans/quilt-source-control/14, Export): the
|
|
2
|
-
// patches of a line in order, each with everything the exported git commit is made from, so a
|
|
3
|
-
// driver (export-git.js) can write the same git objects from them on any machine. The git
|
|
4
|
-
// fields are worked out here, from the commit record and the merge row only, so the same patch
|
|
5
|
-
// always gives the same commit id: nothing from the clock, the driver or a member's current
|
|
6
|
-
// profile gets in (14, Determinism).
|
|
7
|
-
import { QuiltError } from './service.js'
|
|
8
|
-
|
|
9
|
-
const EXPORT_VERSION = 1
|
|
10
|
-
const COMMITTER = { name: 'Quilt', email: 'export@heyquilt.com' }
|
|
11
|
-
|
|
12
|
-
/** A member slug safe for an email local part. */
|
|
13
|
-
const slugOf = (id) => String(id || 'unknown').replace(/^[a-z]+:/, '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'unknown'
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* The git identity of a commit's author, frozen in the commit record. A stub member from a git
|
|
17
|
-
* import keeps the email git had (it was public there); everyone else gets the noreply address
|
|
18
|
-
* of 14 (`<slug>@people|agents.noreply.heyquilt.com`), since no member has opted in to a
|
|
19
|
-
* public email yet.
|
|
20
|
-
*/
|
|
21
|
-
export function exportAuthor (author = {}) {
|
|
22
|
-
const id = String(author.member_id || '')
|
|
23
|
-
const kind = author.kind === 'agent' ? 'agent' : 'person'
|
|
24
|
-
const stub = id.startsWith('stub:') ? id.slice(5) : ''
|
|
25
|
-
const email = stub.includes('@') ? stub : `${slugOf(id)}@${kind === 'agent' ? 'agents' : 'people'}.noreply.heyquilt.com`
|
|
26
|
-
const name = String(author.name || stub || slugOf(id)).replace(/[<>\n]/g, '').trim() || slugOf(id)
|
|
27
|
-
return { name, email, slug: slugOf(id), kind, ...(author.tool ? { tool: author.tool } : {}), ...(author.model ? { model: author.model } : {}) }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** The commit message with 14's trailers, in their fixed order. */
|
|
31
|
-
export function exportMessage ({ commit, line, patch_no: patchNo, git_sha: gitSha }) {
|
|
32
|
-
const a = exportAuthor(commit.author)
|
|
33
|
-
const trailers = [
|
|
34
|
-
`Quilt-Commit: ${commit.id}`,
|
|
35
|
-
`Quilt-Square: ${commit.square_id}`,
|
|
36
|
-
`Quilt-Patch: ${line}/${patchNo}`,
|
|
37
|
-
`Quilt-Author: ${a.slug} (${[a.kind, a.tool, a.model].filter(Boolean).join(', ')})`,
|
|
38
|
-
...(commit.on_behalf_of ? [`Quilt-On-Behalf-Of: ${slugOf(commit.on_behalf_of)}`] : []),
|
|
39
|
-
...(commit.task_id ? [`Quilt-Task: ${commit.task_id}`] : []),
|
|
40
|
-
...Object.keys(commit.stack || {}).sort().map((k) => `Quilt-Stack-${k}: ${commit.stack[k]}`),
|
|
41
|
-
...(gitSha ? [`Quilt-Git-Sha: ${gitSha}`] : [])
|
|
42
|
-
]
|
|
43
|
-
const body = String(commit.message || '').replace(/\s+$/, '')
|
|
44
|
-
return `${body || '(no message)'}\n\n${trailers.join('\n')}\n`
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function exportOps (svc) {
|
|
48
|
-
const { quiltFor, getCommit, store } = svc.internals
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* GET /quilts/:q/export/git: the patches on `line` after `after`, oldest first, at most
|
|
52
|
-
* `limit`: { version, line, patch_count, patches: [{ patch_no, commit, parent, author,
|
|
53
|
-
* committer, message, changes }] }. `changes` are the patch commit's changes against its
|
|
54
|
-
* first parent (the previous patch), which is what the exported commit changes too.
|
|
55
|
-
*/
|
|
56
|
-
async function exportPatches (me, ref, { line = 'main', after = -1, limit = 200 } = {}) {
|
|
57
|
-
const q = await quiltFor(me, ref)
|
|
58
|
-
const ln = await store.getLine(q.id, line)
|
|
59
|
-
if (!ln) throw new QuiltError(404, 'not_found', `no line ${line}`)
|
|
60
|
-
const max = Math.max(1, Math.min(Number(limit) || 200, 1000))
|
|
61
|
-
const rows = await store.patches(q.id, line, { after: Number(after), limit: max, desc: false })
|
|
62
|
-
const patches = []
|
|
63
|
-
for (const row of rows) {
|
|
64
|
-
const c = await getCommit(q.id, row.commit)
|
|
65
|
-
const gitSha = store.gitShaOf ? await store.gitShaOf(q.id, c.id) : null
|
|
66
|
-
const a = exportAuthor(c.author)
|
|
67
|
-
patches.push({
|
|
68
|
-
patch_no: row.patch_no,
|
|
69
|
-
commit: c.id,
|
|
70
|
-
parent: c.parents[0] || null,
|
|
71
|
-
author: { name: a.name, email: a.email, at: Math.floor((c.authored_at || 0) / 1000) },
|
|
72
|
-
committer: { ...COMMITTER, at: Math.floor((row.at || 0) / 1000) },
|
|
73
|
-
message: exportMessage({ commit: c, line, patch_no: row.patch_no, git_sha: gitSha }),
|
|
74
|
-
changes: c.changes.map((ch) => (ch.op === 'delete' ? { path: ch.path, op: 'delete' } : { path: ch.path, op: 'put', blob: ch.blob, mode: ch.mode }))
|
|
75
|
-
})
|
|
76
|
-
}
|
|
77
|
-
return { version: EXPORT_VERSION, quilt: q.id, line, patch_count: ln.patch_count, patches }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return { exportPatches }
|
|
81
|
-
}
|