heyquilt 0.0.1 → 0.3.106
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/LICENSE +85 -0
- package/README.md +633 -2
- package/RELEASES.md +862 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +849 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +591 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +187 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +177 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +110 -0
- package/src/ai-vm.js +522 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +136 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +133 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1219 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +545 -0
- package/src/api/relay-machines.js +92 -0
- package/src/api/releases.js +87 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +252 -0
- package/src/api/routes/ai-vm.js +495 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +179 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +232 -0
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +654 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +875 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +375 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +128 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +248 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +97 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +924 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +45 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1258 -0
- package/src/merge3.js +240 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +183 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +134 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +214 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +166 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1691 -0
- package/src/relay-metrics.js +140 -0
- package/src/relay-outbound.js +1273 -0
- package/src/relay-sync.js +716 -0
- package/src/relay-turn-sync.js +506 -0
- package/src/relay-upstream.js +804 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +138 -0
- package/src/report.js +129 -0
- package/src/runner.js +307 -0
- package/src/same-edit.js +18 -0
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +4214 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7446 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +197 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +145 -0
- package/src/turn-sync.js +212 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2494 -0
- package/src/ui/app.js +546 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +108 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +433 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +150 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1322 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +365 -0
- package/src/ui/loom.js +786 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/rail-model.js +75 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +3292 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +281 -0
- package/src/ui/workspaces.js +547 -0
- package/src/ui-server.js +1381 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +192 -0
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/clash.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Commits from outside that a folder can't bring in (git couldn't read a file
|
|
2
|
+
// they change: too large, or behind a filter such as Git LFS) are handed to ONE
|
|
3
|
+
// member as a task on the board, instead of every folder behind on that branch
|
|
4
|
+
// telling its AI. Files both sides changed never come here: Quilt combines them
|
|
5
|
+
// itself (upstream.js combine). Each folder that reports the clash works out the same owner from
|
|
6
|
+
// presence; only the owner's Quilt writes the task, under an id derived from
|
|
7
|
+
// the branch and the upstream commit, so two that race write the same task.
|
|
8
|
+
// Pure: Session (reviewClash) reads presence and the board and applies these.
|
|
9
|
+
import crypto from 'node:crypto'
|
|
10
|
+
import { MAX_FILES, MAX_TITLE } from './tasks.js'
|
|
11
|
+
|
|
12
|
+
const HEX_ID = /^[0-9a-f]{16}$/
|
|
13
|
+
const SHA = /^[0-9a-f]{7,64}$/
|
|
14
|
+
|
|
15
|
+
/** The task id for a clash on `branch` with upstream commit `sha`: the same in every member's Quilt. */
|
|
16
|
+
export function clashTaskId (branch, sha) {
|
|
17
|
+
return crypto.createHash('sha256').update(`quilt-clash\n${branch}\n${sha}`).digest('hex').slice(0, 16)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A clash record from the room's "clashes" map (branch -> record), or null for anything else. */
|
|
21
|
+
export function readClash (value, branch) {
|
|
22
|
+
if (!value || typeof value !== 'object') return null
|
|
23
|
+
if (typeof value.task !== 'string' || !HEX_ID.test(value.task)) return null
|
|
24
|
+
if (typeof value.sha !== 'string' || !SHA.test(value.sha)) return null
|
|
25
|
+
if (typeof value.upstream !== 'string' || !value.upstream || value.upstream.length > 200) return null
|
|
26
|
+
const ts = Number.isFinite(value.ts) ? value.ts : 0
|
|
27
|
+
return { branch, task: value.task, sha: value.sha, upstream: value.upstream, ts }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether a member's presence shows a live AI session working through its app (persona.js). */
|
|
31
|
+
export function hasAi (st) {
|
|
32
|
+
return !!st && Array.isArray(st.personas) && st.personas.some((p) => p && typeof p.name === 'string' && p.name)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The members who may take this clash: their folder is on `branch` with `upstream` as its
|
|
37
|
+
* upstream and reports clashing files (not a diverged branch: that history is the folder's own),
|
|
38
|
+
* their Quilt hands clashes out (git.clash 1), and they may change the clashing files.
|
|
39
|
+
* `states`: [[clientID, presence state]], this member's own included.
|
|
40
|
+
* Each: { id, name, kind, ai, rank } (rank 0 an agent member, 1 a person with an AI session, 2 a person).
|
|
41
|
+
*/
|
|
42
|
+
export function clashCandidates (states, { branch, upstream }) {
|
|
43
|
+
const out = []
|
|
44
|
+
for (const [id, st] of states) {
|
|
45
|
+
if (!st || typeof st.name !== 'string' || !st.name || !st.git || typeof st.git !== 'object') continue
|
|
46
|
+
const g = st.git
|
|
47
|
+
const u = g.upstream
|
|
48
|
+
if (g.clash !== 1 || g.branch !== branch || !u || u.name !== upstream) continue
|
|
49
|
+
// A folder whose fetch fails can't pull, so its AI couldn't merge.
|
|
50
|
+
if (!(u.conflicts > 0) || u.diverged || u.mayWrite !== true || u.fetchOk === false) continue
|
|
51
|
+
const agent = st.kind === 'agent'
|
|
52
|
+
const ai = !agent && hasAi(st)
|
|
53
|
+
out.push({ id, name: st.name, kind: agent ? 'agent' : 'human', ai, rank: agent ? 0 : ai ? 1 : 2 })
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The candidates in the order they take the clash: an agent member first, then a person with an AI, then anyone; the lowest client id among equals. */
|
|
59
|
+
export function clashOrder (candidates) {
|
|
60
|
+
return [...candidates].sort((a, b) => a.rank - b.rank || a.id - b.id)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Who takes the clash (the first in clashOrder not in `except`), or null. */
|
|
64
|
+
export function pickClashOwner (candidates, { except = [] } = {}) {
|
|
65
|
+
return clashOrder(candidates.filter((c) => !except.includes(c.name)))[0] || null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The presence state of whoever `assignee` is (a member, or one of a member's AI sessions),
|
|
70
|
+
* counted only when that member's folder is on `branch`; null when they are not here.
|
|
71
|
+
*/
|
|
72
|
+
export function holderOf (states, assignee, branch) {
|
|
73
|
+
for (const [id, st] of states) {
|
|
74
|
+
if (!st || !st.git || st.git.branch !== branch) continue
|
|
75
|
+
if (st.name === assignee || (Array.isArray(st.personas) && st.personas.some((p) => p && p.name === assignee))) return { id, st, rank: st.kind === 'agent' ? 0 : hasAi(st) ? 1 : 2 }
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** How a task's assignee is named in a notice and on the branch list: an agent by name, a person as "<name>'s AI". */
|
|
81
|
+
export function ownerLabel ({ assignee, forAi }) {
|
|
82
|
+
return forAi ? `${assignee}'s AI` : assignee
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const n = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
|
|
86
|
+
|
|
87
|
+
/** The task's title: "Bring 4 commits from origin/main into the session: src/a.js, src/b.js clash". */
|
|
88
|
+
export function clashTitle ({ upstream, behind, conflicts = [] }) {
|
|
89
|
+
const head = `Bring ${n(behind, 'commit')} from ${upstream} into the session: `
|
|
90
|
+
const paths = conflicts.map((c) => c.path)
|
|
91
|
+
const tail = paths.length === 1 ? ' clashes' : ' clash'
|
|
92
|
+
const text = (shown) => {
|
|
93
|
+
const rest = paths.length - shown.length
|
|
94
|
+
return head + shown.join(', ') + (rest ? ` and ${rest} more` : '') + tail
|
|
95
|
+
}
|
|
96
|
+
let shown = []
|
|
97
|
+
for (const p of paths) {
|
|
98
|
+
const next = [...shown, p]
|
|
99
|
+
if (shown.length && text(next).length > MAX_TITLE) break
|
|
100
|
+
shown = next
|
|
101
|
+
}
|
|
102
|
+
return text(shown).slice(0, MAX_TITLE)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The files the task lists (the board keeps at most MAX_FILES). */
|
|
106
|
+
export function clashFiles (conflicts = []) {
|
|
107
|
+
return [...new Set(conflicts.map((c) => c.path))].slice(0, MAX_FILES)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The task's description, as its first comment: each file and why. Nobody is asked to pull or merge. */
|
|
111
|
+
export function clashBrief ({ branch, upstream, sha, behind, conflicts = [] }) {
|
|
112
|
+
const lines = [`${n(behind, 'commit')} on ${upstream} (up to ${sha.slice(0, 7)}) change files Quilt can't read here, so it brought nothing in yet:`]
|
|
113
|
+
for (const c of conflicts.slice(0, 30)) lines.push(`- ${c.path}: ${c.why}`)
|
|
114
|
+
if (conflicts.length > 30) lines.push(`- and ${conflicts.length - 30} more`)
|
|
115
|
+
lines.push('', `Files both sides changed are combined by Quilt on its own; nothing here needs merging by hand. Once a folder on \`${branch}\` that can read these files is at ${sha.slice(0, 7)} or later, every other folder follows it and Quilt closes this task by itself: no need to move it.`)
|
|
116
|
+
return lines.join('\n')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The notice for a member whose folder is behind but who is not merging: leave it to the owner. */
|
|
120
|
+
export function leaveItNotice ({ label, upstream, task, facts }) {
|
|
121
|
+
return `${label} is bringing in the commits from ${upstream} (task ${task}); leave those files to them.${facts ? ` ${facts}` : ''}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** What a folder that reports the clash knows about it, without the advice to pull. */
|
|
125
|
+
export function clashFacts ({ branch, upstream, behind, conflicts = [] }) {
|
|
126
|
+
const list = conflicts.slice(0, 4).map((c) => `${c.path} (${c.why})`).join('; ') + (conflicts.length > 4 ? `; and ${conflicts.length - 4} more` : '')
|
|
127
|
+
return `${branch} is ${n(behind, 'commit')} behind ${upstream}, and Quilt can't read ${list} to bring them in.`
|
|
128
|
+
}
|
package/src/cloud-ai.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// A person's AI on its own cloud machine (docs/superpowers/specs/2026-10-17-ai-vm.md) is one of
|
|
2
|
+
// that person's AI sessions: its presence says whose (`aiOf`), and it answers for
|
|
3
|
+
// "<person>'s AI" only while none of the person's AI sessions on a computer is live. Pure, so
|
|
4
|
+
// the relay (which wakes a suspended one) and every app (which shows it and reads its inbox)
|
|
5
|
+
// decide the same way.
|
|
6
|
+
import { aiName } from './ui/chat.js'
|
|
7
|
+
|
|
8
|
+
/** The label a cloud AI's name carries: "Daniel · cloud". */
|
|
9
|
+
export const CLOUD_LABEL = 'cloud'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether one of `person`'s AI sessions on a computer is live: a presence of theirs (not a
|
|
13
|
+
* cloud AI's) publishing an AI session that isn't a "work on its own" run.
|
|
14
|
+
*/
|
|
15
|
+
export function localAiLive (states, person) {
|
|
16
|
+
for (const s of states || []) {
|
|
17
|
+
if (!s || s.name !== person || s.aiOf || !Array.isArray(s.personas)) continue
|
|
18
|
+
if (s.personas.some((p) => p && typeof p.name === 'string' && !p.auto)) return true
|
|
19
|
+
}
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The names of `person`'s AI sessions on computers: what they say never wakes the cloud AI. */
|
|
24
|
+
export function localAiNames (states, person) {
|
|
25
|
+
const out = []
|
|
26
|
+
for (const s of states || []) {
|
|
27
|
+
if (!s || s.name !== person || s.aiOf || !Array.isArray(s.personas)) continue
|
|
28
|
+
for (const p of s.personas) if (p && typeof p.name === 'string') out.push(p.name)
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How a cloud AI named `name`, of `person`, reads the inbox (inbox.js): its own name always;
|
|
35
|
+
* "<person>'s AI" and tasks for it only while no AI session of theirs on a computer is live.
|
|
36
|
+
*/
|
|
37
|
+
export function cloudReader ({ name, person, states = [], aliases = [] }) {
|
|
38
|
+
const local = localAiLive(states, person)
|
|
39
|
+
return {
|
|
40
|
+
name,
|
|
41
|
+
aliases: local ? [...aliases] : [...aliases, aiName(person)],
|
|
42
|
+
agent: true,
|
|
43
|
+
asAi: false,
|
|
44
|
+
...(local ? {} : { of: person }),
|
|
45
|
+
own: localAiNames(states, person)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether a presence is a cloud AI's (its relay checked `aiOf` against its pass). */
|
|
50
|
+
export const isCloudAi = (state) => !!(state && typeof state.aiOf === 'string' && state.aiOf)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Commit requests (quilt_request_commit): an AI or a person asks for a commit, says what it
|
|
2
|
+
// is for and which files changed, and the session owner (whose machine usually commits) is
|
|
3
|
+
// told with a direct message. Shared by the folder's Session and the relay's hosted MCP so
|
|
4
|
+
// both build the same request and the same message.
|
|
5
|
+
import { isSafeRelPath } from './pathrules.js'
|
|
6
|
+
|
|
7
|
+
export const MAX_COMMIT_MESSAGE = 500
|
|
8
|
+
export const MAX_COMMIT_DESCRIPTION = 2000
|
|
9
|
+
export const MAX_COMMIT_FILES = 100
|
|
10
|
+
|
|
11
|
+
/** The changed files an agent listed: clean relative paths, no duplicates, at most MAX_COMMIT_FILES. */
|
|
12
|
+
export function cleanCommitFiles (files) {
|
|
13
|
+
if (files == null) return []
|
|
14
|
+
if (!Array.isArray(files)) throw new Error('files must be a list of paths')
|
|
15
|
+
const out = []
|
|
16
|
+
for (const f of files) {
|
|
17
|
+
const rel = String(f == null ? '' : f).trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
|
|
18
|
+
if (!rel) continue
|
|
19
|
+
if (!isSafeRelPath(rel)) throw new Error(`not a path in the project: ${String(f).slice(0, 200)}`)
|
|
20
|
+
if (!out.includes(rel)) out.push(rel)
|
|
21
|
+
}
|
|
22
|
+
if (out.length > MAX_COMMIT_FILES) throw new Error(`list at most ${MAX_COMMIT_FILES} files`)
|
|
23
|
+
return out
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The owner's name from a member list ({ name, role }), or ''. */
|
|
27
|
+
export function ownerName (members) {
|
|
28
|
+
const o = (members || []).find((m) => m && m.role === 'owner' && m.name)
|
|
29
|
+
return o ? String(o.name) : ''
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A new open request. Throws when there is nothing to say. */
|
|
33
|
+
export function makeCommitRequest ({ id, by, message, description = '', files = [], branch = '', ts = Date.now() }) {
|
|
34
|
+
const msg = String(message || '').trim().slice(0, MAX_COMMIT_MESSAGE)
|
|
35
|
+
if (!msg) throw new Error('say what the commit is for')
|
|
36
|
+
const desc = String(description || '').trim().slice(0, MAX_COMMIT_DESCRIPTION)
|
|
37
|
+
const list = cleanCommitFiles(files)
|
|
38
|
+
return { id, by, message: msg, ...(desc ? { description: desc } : {}), ...(list.length ? { files: list } : {}), ...(branch ? { branch } : {}), ts, state: 'open' }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the owner is told: who wants a commit, what for, which files, and how to settle it. */
|
|
42
|
+
export function commitRequestText (r, { defaultBranch = 'main' } = {}) {
|
|
43
|
+
const branch = r.branch && r.branch !== defaultBranch ? ` on \`${r.branch}\`` : ''
|
|
44
|
+
const files = r.files && r.files.length
|
|
45
|
+
? ` Files (${r.files.length}): ${r.files.slice(0, 20).join(', ')}${r.files.length > 20 ? `, and ${r.files.length - 20} more` : ''}.`
|
|
46
|
+
: ''
|
|
47
|
+
const desc = r.description ? ` ${r.description.replace(/\s+/g, ' ')}` : ''
|
|
48
|
+
return `📌 Commit requested${branch} (${r.id}): ${r.message.replace(/[.!?]+$/, '')}.${desc}${files} ` +
|
|
49
|
+
'Commit with git when it is a good moment (quilt_commit_status), then mark it done with quilt_commit_request_done.'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The chat message that tells the owner, or null when there is no owner to tell or the owner asked. */
|
|
53
|
+
export function commitRequestMessage (r, { owner, id, defaultBranch, of = null }) {
|
|
54
|
+
if (!owner || owner === r.by) return null
|
|
55
|
+
return { id, by: r.by, to: owner, text: commitRequestText(r, { defaultBranch }), ts: r.ts, kind: 'commit', commit: r.id, ...(of ? { of } : {}) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A message that tells the owner about a commit request: settled by committing, not by a reply. */
|
|
59
|
+
export const commitRequestNote = (m) => !!m && m.kind === 'commit'
|
package/src/commit.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Commit requests, committed in a person's folder with git: exactly the files a request names
|
|
2
|
+
// (those that differ from HEAD), with its message, then pushed to the branch's upstream. This is
|
|
3
|
+
// the way in when the relay can't commit for the asker (relay-commit.js: no GitHub token, a
|
|
4
|
+
// repository not on GitHub, or the owner keeps agents from committing): a person commits it from
|
|
5
|
+
// the app's commit panel (session.js: commitRequest).
|
|
6
|
+
//
|
|
7
|
+
// Committing touches no file: `git commit --only -- <paths>` moves the branch and the index
|
|
8
|
+
// entries of those paths, and leaves everything else (other people's uncommitted work, files
|
|
9
|
+
// someone staged by hand) as it was. Quilt never merges history: a push git refuses (the
|
|
10
|
+
// remote moved on) is reported, not forced.
|
|
11
|
+
//
|
|
12
|
+
// The pure parts (who changed which uncommitted file, what a person should know before
|
|
13
|
+
// committing a request, the commit message) are here too, so the app, the daemon and tests agree.
|
|
14
|
+
import { execFile } from 'node:child_process'
|
|
15
|
+
import { isSafeRelPath, globMatcher } from './pathrules.js'
|
|
16
|
+
|
|
17
|
+
export const COMMIT_TIMEOUT_MS = 60 * 1000 // a pre-commit hook may run tests
|
|
18
|
+
export const PUSH_TIMEOUT_MS = 90 * 1000
|
|
19
|
+
export const MAX_REQUEST_FILES = 500
|
|
20
|
+
export const MAX_MESSAGE = 500
|
|
21
|
+
// How long a request is left alone after it is made: the asker's AI may still be saving its files.
|
|
22
|
+
export const REQUEST_SETTLE_MS = 60 * 1000
|
|
23
|
+
|
|
24
|
+
const NO_PROMPT = { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_OPTIONAL_LOCKS: '0' }
|
|
25
|
+
|
|
26
|
+
/** Runs git: { ok, out, err }. Never rejects. */
|
|
27
|
+
function git (root, args, { timeout = 15000, env = {} } = {}) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
let child
|
|
30
|
+
try {
|
|
31
|
+
child = execFile(process.env.QUILT_GIT || 'git', args, { cwd: root, timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...NO_PROMPT, ...env } },
|
|
32
|
+
(err, stdout, stderr) => resolve({ ok: !err, out: String(stdout || ''), err: String(stderr || (err && err.message) || '') }))
|
|
33
|
+
} catch (err) { return resolve({ ok: false, out: '', err: err.message }) }
|
|
34
|
+
child.stdin.end()
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The paths a request may name: relative, inside the folder, no duplicates. */
|
|
39
|
+
export function cleanFiles (files) {
|
|
40
|
+
const out = []
|
|
41
|
+
for (const f of Array.isArray(files) ? files : []) {
|
|
42
|
+
const p = String(f || '').trim().replace(/\\/g, '/').replace(/^\.\//, '')
|
|
43
|
+
if (!isSafeRelPath(p) || out.includes(p)) continue
|
|
44
|
+
out.push(p)
|
|
45
|
+
if (out.length >= MAX_REQUEST_FILES) break
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which of `files` differ from HEAD (changed, new or deleted, not ignored): Map path -> 'M' | 'A' | 'D'.
|
|
52
|
+
* Null when git can't say.
|
|
53
|
+
*/
|
|
54
|
+
export async function uncommitted (root, files = null) {
|
|
55
|
+
const args = ['-c', 'core.quotePath=false', 'status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=all']
|
|
56
|
+
if (files) {
|
|
57
|
+
if (!files.length) return new Map()
|
|
58
|
+
args.push('--', ...files.map((f) => `:(literal)${f}`))
|
|
59
|
+
}
|
|
60
|
+
const r = await git(root, args)
|
|
61
|
+
if (!r.ok) return null
|
|
62
|
+
const out = new Map()
|
|
63
|
+
for (const rec of r.out.split('\0')) {
|
|
64
|
+
if (rec.length < 4) continue
|
|
65
|
+
const xy = rec.slice(0, 2)
|
|
66
|
+
const rel = rec.slice(3)
|
|
67
|
+
if (xy === '!!') continue
|
|
68
|
+
out.set(rel, xy === '??' || xy.includes('A') ? 'A' : xy.includes('D') ? 'D' : 'M')
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** When each path was last committed (ms), from the newest `depth` commits: Map path -> ts. */
|
|
74
|
+
export async function lastCommitted (root, { depth = 2000 } = {}) {
|
|
75
|
+
const r = await git(root, ['-c', 'core.quotePath=false', 'log', `-n${depth}`, '--format=%x00%ct', '--name-only', 'HEAD'])
|
|
76
|
+
const out = new Map()
|
|
77
|
+
if (!r.ok) return out
|
|
78
|
+
let ts = 0
|
|
79
|
+
for (const line of r.out.split('\n')) {
|
|
80
|
+
if (line.startsWith('\0')) { ts = Number(line.slice(1)) * 1000 || 0; continue }
|
|
81
|
+
if (line && !out.has(line)) out.set(line, ts)
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Commits the request's files that differ from HEAD, and pushes. Resolves
|
|
88
|
+
* { hash, files, pushed, pushError, nothing } or throws with a plain reason (git refused, a hook failed).
|
|
89
|
+
* `push` false only commits. `upstream` is { remote, name } (gitstate.upstreamOf); without one nothing is pushed.
|
|
90
|
+
*/
|
|
91
|
+
export async function commitFiles (root, { files, message, push = true, upstream = null, branch = '' } = {}) {
|
|
92
|
+
const wanted = cleanFiles(files)
|
|
93
|
+
const changed = await uncommitted(root, wanted)
|
|
94
|
+
if (!changed) throw new Error('git could not read this folder')
|
|
95
|
+
const paths = wanted.filter((f) => changed.has(f))
|
|
96
|
+
if (!paths.length) return { hash: null, files: [], pushed: false, pushError: null, nothing: true }
|
|
97
|
+
const spec = paths.map((f) => `:(literal)${f}`)
|
|
98
|
+
// New files must be known to git before `commit --only` takes them; deletions are taken as they are.
|
|
99
|
+
const added = paths.filter((f) => changed.get(f) === 'A')
|
|
100
|
+
if (added.length) {
|
|
101
|
+
const a = await git(root, ['add', '--', ...added.map((f) => `:(literal)${f}`)])
|
|
102
|
+
if (!a.ok) throw new Error(`git add failed: ${firstLine(a.err)}`)
|
|
103
|
+
}
|
|
104
|
+
const c = await git(root, ['commit', '--only', '--no-edit', '-m', message, '--', ...spec], { timeout: COMMIT_TIMEOUT_MS })
|
|
105
|
+
if (!c.ok) {
|
|
106
|
+
// Leave nothing half-done: what was added for this commit goes back to untracked.
|
|
107
|
+
if (added.length) await git(root, ['reset', '-q', '--', ...added.map((f) => `:(literal)${f}`)])
|
|
108
|
+
throw new Error(`git commit failed: ${firstLine(c.err || c.out)}`)
|
|
109
|
+
}
|
|
110
|
+
const hash = (await git(root, ['rev-parse', 'HEAD'])).out.trim()
|
|
111
|
+
if (!push || !upstream || !upstream.remote || upstream.remote === '.') return { hash, files: paths, pushed: false, pushError: upstream ? null : 'this branch has no upstream to push to', nothing: false }
|
|
112
|
+
const target = String(upstream.name || '').startsWith(`${upstream.remote}/`) ? upstream.name.slice(upstream.remote.length + 1) : branch
|
|
113
|
+
const p = await git(root, ['-c', 'core.askPass=', 'push', '--quiet', upstream.remote, `HEAD:refs/heads/${target}`], {
|
|
114
|
+
timeout: PUSH_TIMEOUT_MS,
|
|
115
|
+
env: { GIT_ASKPASS: '', SSH_ASKPASS: '', GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes' }
|
|
116
|
+
})
|
|
117
|
+
return { hash, files: paths, pushed: p.ok, pushError: p.ok ? null : pushProblem(p.err, upstream.remote, target), nothing: false }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const firstLine = (s) => String(s || '').split('\n').map((l) => l.replace(/^(fatal|error):\s*/i, '').trim()).find(Boolean)?.slice(0, 200) || 'unknown error'
|
|
121
|
+
|
|
122
|
+
/** Why a push failed, in plain words (never a URL: it may carry credentials). */
|
|
123
|
+
export function pushProblem (stderr, remote = 'origin', branch = '') {
|
|
124
|
+
const e = String(stderr || '').toLowerCase()
|
|
125
|
+
if (/non-fast-forward|fetch first|rejected/.test(e)) return `${remote}/${branch} has commits this folder doesn't have yet: the commit is made here and will be pushed after Quilt catches the branch up`
|
|
126
|
+
if (/could not read (username|password)|terminal prompts disabled|authentication failed|permission denied|repository not found|could not read from remote|access denied|returned error: 40[13]/.test(e)) return `git can't sign in to ${remote} from this folder to push`
|
|
127
|
+
if (/could not resolve host|failed to connect|unable to access|network is unreachable|timed out/.test(e)) return `can't reach ${remote} (no network?)`
|
|
128
|
+
return `git push failed: ${firstLine(stderr).replace(/[a-z]+:\/\/[^\s'"]*@/gi, '')}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The commit message: what the request says, and who asked (and for which task). */
|
|
132
|
+
export function commitMessage (r) {
|
|
133
|
+
const first = String(r.message || '').trim().split('\n')[0].slice(0, 120) || 'Changes from the Quilt session'
|
|
134
|
+
const rest = String(r.message || '').trim().split('\n').slice(1).join('\n').trim()
|
|
135
|
+
const by = r.by ? `Asked for by ${r.by} in Quilt${r.task ? ` (task ${r.task.id}: ${r.task.title})` : ''}.` : ''
|
|
136
|
+
return [first, rest, by].filter(Boolean).join('\n\n')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Who made each uncommitted change, from the session's history: for each path in `dirty`, the
|
|
141
|
+
* people whose own edits (not pulls from git) to it came after it was last committed.
|
|
142
|
+
* Returns Map path -> [names], newest editor last. `history`: entries, oldest first.
|
|
143
|
+
*/
|
|
144
|
+
export function editorsSince (dirty, history, committedAt = new Map()) {
|
|
145
|
+
const out = new Map()
|
|
146
|
+
for (const rel of dirty) out.set(rel, [])
|
|
147
|
+
for (const e of history || []) {
|
|
148
|
+
if (!e || !out.has(e.path) || e.pulled || !e.by) continue
|
|
149
|
+
if ((e.ts || 0) <= (committedAt.get(e.path) || 0)) continue
|
|
150
|
+
const list = out.get(e.path)
|
|
151
|
+
const i = list.indexOf(e.by)
|
|
152
|
+
if (i >= 0) list.splice(i, 1)
|
|
153
|
+
list.push(e.by)
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Uncommitted work grouped by who made it: [{ by, files }], most files first. A file several
|
|
160
|
+
* people changed is listed under each. Files no history entry explains go under ''.
|
|
161
|
+
*/
|
|
162
|
+
export function uncommittedByPerson (editors) {
|
|
163
|
+
const groups = new Map()
|
|
164
|
+
for (const [rel, names] of editors) {
|
|
165
|
+
for (const n of names.length ? names : ['']) {
|
|
166
|
+
if (!groups.has(n)) groups.set(n, [])
|
|
167
|
+
groups.get(n).push(rel)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return [...groups].map(([by, files]) => ({ by, files: files.sort() })).sort((a, b) => b.files.length - a.files.length || a.by.localeCompare(b.by))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* What the person committing a request should know first, or '': a file with conflict markers,
|
|
175
|
+
* one that also holds others' uncommitted changes (they would go in too), one someone else holds,
|
|
176
|
+
* or an asker still at work.
|
|
177
|
+
*/
|
|
178
|
+
export function requestWarning (r, { editors, claims = [], markers = new Set(), taskEditors = new Set(), busy = [], now = Date.now() } = {}) {
|
|
179
|
+
if (now - (r.ts || 0) < REQUEST_SETTLE_MS) return 'just asked'
|
|
180
|
+
if (busy.includes(r.by)) return `${r.by} is still working`
|
|
181
|
+
for (const [rel, names] of editors) {
|
|
182
|
+
if (markers.has(rel)) return `${rel} has conflict markers`
|
|
183
|
+
const others = names.filter((n) => n !== r.by && !taskEditors.has(n))
|
|
184
|
+
if (others.length) return `${rel} also has uncommitted changes by ${others.join(', ')}`
|
|
185
|
+
const held = claims.find((c) => c && c.by && c.by !== r.by && globMatcher(String(c.pattern || ''))(rel))
|
|
186
|
+
if (held) return `${held.by} holds ${rel}`
|
|
187
|
+
}
|
|
188
|
+
return ''
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ------------------------------------------------------------- words --
|
|
192
|
+
// The same for the local and the hosted tools, and the HTTP routes' docs.
|
|
193
|
+
|
|
194
|
+
export const REQUEST_COMMIT_DESCRIPTION = 'Ask a person to commit your finished work, when you can\'t get it into git yourself (the owner keeps agents from committing, nobody has connected GitHub, or the repository is not on GitHub). ' +
|
|
195
|
+
'A person with git on this branch commits exactly these files (those that differ from git) with your message, from their app, and you are told the commit. ' +
|
|
196
|
+
'Give the files you changed; without them, the task\'s changes (task) or every change of yours on record are asked for.'
|
|
197
|
+
|
|
198
|
+
/** Open requests (with ids, files and what holds them up) and recent commits, as lines for an agent. */
|
|
199
|
+
export function describeCommitRequests (c, { canCommit = false } = {}) {
|
|
200
|
+
const lines = []
|
|
201
|
+
const open = c.open || []
|
|
202
|
+
if (open.length) {
|
|
203
|
+
lines.push('Open commit requests:')
|
|
204
|
+
for (const r of open) {
|
|
205
|
+
const files = Array.isArray(r.files) && r.files.length ? ` (${r.files.length} file${r.files.length === 1 ? '' : 's'}: ${r.files.slice(0, 8).join(', ')}${r.files.length > 8 ? ', …' : ''})` : ''
|
|
206
|
+
lines.push(`- [${r.id}] ${r.by}${r.branch ? ` on \`${r.branch}\`` : ''}: ${r.message}${r.task ? ` (task ${r.task.id})` : ''}${files}${r.error ? `. Last try: ${r.error}` : ''}`)
|
|
207
|
+
}
|
|
208
|
+
lines.push(canCommit ? 'This folder has git: its app commits a request with its Commit button.' : 'A person with git on that branch commits them from their app; the asker is told the commit.')
|
|
209
|
+
} else lines.push('No open commit requests.')
|
|
210
|
+
const recent = (c.recent || []).filter((r) => r.hash)
|
|
211
|
+
if (recent.length) lines.push('Committed lately:', ...recent.map((r) => `- ${r.hash.slice(0, 7)} ${r.message} (${r.by}${r.pushed === false ? `; not pushed${r.pushError ? `: ${r.pushError}` : ''}` : ''})`))
|
|
212
|
+
return lines.join('\n')
|
|
213
|
+
}
|