heyquilt 0.3.90 → 0.3.107
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/README.md +14 -11
- package/RELEASES.md +135 -2
- package/bin/quilt.js +45 -12
- package/package.json +3 -3
- package/src/account.js +28 -0
- package/src/agents/claude-code.js +11 -4
- package/src/agents/cursor-transcripts.js +11 -4
- package/src/ai-vm-signin.js +14 -2
- package/src/ai-vm.js +150 -8
- package/src/api/agent-auth.js +9 -3
- package/src/api/fly-machines.js +72 -15
- package/src/api/memory-store.js +142 -6
- package/src/api/relay-cluster.js +337 -43
- package/src/api/relay-machines.js +35 -3
- package/src/api/releases.js +87 -0
- package/src/api/routes/agents.js +111 -16
- package/src/api/routes/ai-vm.js +146 -19
- package/src/api/routes/orgs.js +3 -1
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/workspaces.js +8 -1
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +40 -9
- package/src/api/supabase-store.js +121 -11
- package/src/autowork.js +3 -15
- package/src/catchup.js +1 -1
- package/src/clash.js +10 -22
- package/src/control.js +18 -15
- package/src/fsutil.js +11 -1
- package/src/gitstate.js +29 -5
- package/src/hooks.js +1 -1
- package/src/mcp-proxy.js +7 -2
- package/src/mcp.js +22 -25
- package/src/merge3.js +94 -12
- package/src/pass-source.js +9 -2
- package/src/pathrules.js +7 -0
- package/src/relay-clash.js +101 -484
- package/src/relay-githttp.js +36 -7
- package/src/relay-mcp.js +19 -7
- package/src/relay-metrics.js +66 -1
- package/src/relay-outbound.js +154 -25
- package/src/relay-sync.js +133 -136
- package/src/relay-turn-sync.js +250 -51
- package/src/relay-upstream.js +49 -179
- package/src/releases.js +16 -9
- package/src/runner.js +3 -2
- 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 +71 -44
- package/src/session.js +347 -262
- package/src/status.js +3 -5
- package/src/sync-words.js +1 -1
- package/src/turn-sync-plan.js +13 -10
- package/src/turn-sync.js +32 -11
- package/src/ui/agent-guide.js +4 -4
- package/src/ui/app.css +208 -23
- package/src/ui/app.js +4 -0
- package/src/ui/cloud-ai-model.js +3 -2
- package/src/ui/common.js +2 -0
- package/src/ui/fileview.js +28 -1
- package/src/ui/home.js +32 -33
- package/src/ui/loom.css +18 -2
- package/src/ui/loom.js +41 -8
- package/src/ui/rail-model.js +6 -9
- package/src/ui/releases.js +1 -1
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -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 +47 -149
- package/src/ui/tree.js +23 -18
- package/src/ui/workspaces.js +5 -0
- package/src/ui-server.js +140 -28
- package/src/update-check.js +1 -1
- package/src/upstream.js +75 -38
- 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/ui/merges.js +0 -158
package/src/api/server.js
CHANGED
|
@@ -27,6 +27,7 @@ import { relayCluster } from './relay-cluster.js'
|
|
|
27
27
|
import crypto from 'node:crypto'
|
|
28
28
|
import { githubRoutes } from './routes/github.js'
|
|
29
29
|
import { githubApp, ensureWebhookSecret, redeliverFailed } from './github-app.js'
|
|
30
|
+
import { releaseSource } from './releases.js'
|
|
30
31
|
import { sessionRoutes } from './routes/sessions.js'
|
|
31
32
|
import { accessTypeRoutes } from './routes/access-types.js'
|
|
32
33
|
import { grantRoutes } from './routes/grants.js'
|
|
@@ -43,6 +44,7 @@ import { aiVmRoutes, cloudAiOf, WAIT_MS as AI_VM_WAIT_MS } from './routes/ai-vm.
|
|
|
43
44
|
import { personaName } from '../persona.js'
|
|
44
45
|
import { CLOUD_LABEL } from '../cloud-ai.js'
|
|
45
46
|
import { routeName, cleanEvent } from './issues.js'
|
|
47
|
+
import { secretRoutes } from './routes/secrets.js'
|
|
46
48
|
|
|
47
49
|
const API_VERSION = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf8')).version
|
|
48
50
|
|
|
@@ -64,7 +66,7 @@ const MCP_TIMEOUT_MS = 30 * 1000
|
|
|
64
66
|
// a change to its grant reaches it as soon as it reaches a connected app.
|
|
65
67
|
const PASS_REUSE_MARGIN_MS = 5 * 60 * 1000
|
|
66
68
|
|
|
67
|
-
export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, siteUrl, apiUrl = 'https://api.heyquilt.com', mailer = { send: async () => { throw new Error('no mailer configured') } }, now = Date.now, log = () => {}, startLimit = 10, inviteLimit = 10, inviteSendLimit = 20, tokenLimit = 30, joinLimit = 20, trustProxy = false, maxStartKeys = 10_000, passKey = '', passLimit = 60, relayUrl = HOSTED_RELAY, mcpLimit = 600, reportKey = '', reportLimit = 10, slowMs = 2000, pruneEveryMs = 60 * 60 * 1000, keepEventsMs = 30 * 24 * 60 * 60 * 1000, keepIssuesMs = 90 * 24 * 60 * 60 * 1000, pruneStartMs = 10_000, relaySecret = '', workspaces = false, fileStore = null, maxFileBytes = 500 * 1024 * 1024, workspaceQuotaBytes = 5 * 1024 * 1024 * 1024, maxWorkspaceFiles = 2000, webhookFetch = globalThis.fetch, webhookLookup, allowLocalWebhooks = false, github = null, githubFetch = globalThis.fetch, fly = null, aiVmWaitMs = AI_VM_WAIT_MS, relayFetch = null, relayFly = null, heavy = null, clusterTiming = {} }) {
|
|
69
|
+
export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, siteUrl, apiUrl = 'https://api.heyquilt.com', mailer = { send: async () => { throw new Error('no mailer configured') } }, now = Date.now, log = () => {}, startLimit = 10, inviteLimit = 10, inviteSendLimit = 20, tokenLimit = 30, joinLimit = 20, trustProxy = false, maxStartKeys = 10_000, passKey = '', passLimit = 60, relayUrl = HOSTED_RELAY, mcpLimit = 600, reportKey = '', reportLimit = 10, slowMs = 2000, pruneEveryMs = 60 * 60 * 1000, keepEventsMs = 30 * 24 * 60 * 60 * 1000, keepIssuesMs = 90 * 24 * 60 * 60 * 1000, pruneStartMs = 10_000, relaySecret = '', workspaces = false, fileStore = null, maxFileBytes = 500 * 1024 * 1024, workspaceQuotaBytes = 5 * 1024 * 1024 * 1024, maxWorkspaceFiles = 2000, webhookFetch = globalThis.fetch, webhookLookup, allowLocalWebhooks = false, github = null, githubFetch = globalThis.fetch, fly = null, aiVmWaitMs = AI_VM_WAIT_MS, aiVmSpareSuspendMs, relayFetch = null, relayFly = null, heavy = null, relayTargets = null, clusterTiming = {}, vaultSecret = '', vaultUrl = 'https://vault.heyquilt.com', allowLocalUpstreams = false, secretWaitMs }) {
|
|
68
70
|
// PASS_SIGNING_KEY. A bad one should stop the API at start, not fail every pass later.
|
|
69
71
|
if (passKey) passPublicKey(passKey)
|
|
70
72
|
const site = String(siteUrl || '').replace(/\/+$/, '')
|
|
@@ -213,7 +215,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
213
215
|
['GET', /^\/healthz$/, async () => ({ ok: true })],
|
|
214
216
|
|
|
215
217
|
// Which optional parts this API has on, for the app and the MCPs to decide what to show. Public.
|
|
216
|
-
['GET', /^\/v1\/features$/, async () => ({ workspaces: !!workspaces })],
|
|
218
|
+
['GET', /^\/v1\/features$/, async () => ({ workspaces: !!workspaces, secrets: !!vaultSecret })],
|
|
217
219
|
|
|
218
220
|
['POST', /^\/v1\/device\/start$/, async (req, body) => {
|
|
219
221
|
limitStarts(req)
|
|
@@ -346,7 +348,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
346
348
|
const deliveries = new Set()
|
|
347
349
|
const trackDelivery = (p) => { deliveries.add(p); p.finally(() => deliveries.delete(p)).catch(() => {}); return p }
|
|
348
350
|
// The relay on many machines: where each room lives, and heavy rooms' own machines (relay-cluster.js).
|
|
349
|
-
const cluster = relayCluster({ store, relayUrl: relay, relaySecret, fetch: relayFetch || githubFetch, now, log, fly: relayFly, ...(heavy ? { limits: heavy } : {}), ...clusterTiming })
|
|
351
|
+
const cluster = relayCluster({ store, relayUrl: relay, relaySecret, fetch: relayFetch || githubFetch, now, log, fly: relayFly, ...(heavy ? { limits: heavy } : {}), ...(relayTargets ? { targets: relayTargets } : {}), ...clusterTiming })
|
|
350
352
|
const ctx = { store, user, person, device, bearer, now, site, apiUrl: api, mailer, log, limit: limitInvites, limitSend: limitInviteSend, limitTokens, limitStarts, spendResume, resumeWindowMs: RESUME_WINDOW_MS, limitJoin, agentAuth, reportKey, limitReports, relaySecret, files, maxFileBytes, workspaceQuotaBytes, maxWorkspaceFiles }
|
|
351
353
|
// GitHub's webhook secret: set on the app by this API at start (github-app.js), kept in the store when the environment gives none.
|
|
352
354
|
const hookSecret = { value: (github && github.webhookSecret) || '' }
|
|
@@ -357,20 +359,23 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
357
359
|
await redeliverFailed({ app, log })
|
|
358
360
|
})().catch((err) => log(`github at start: ${err.message}`))
|
|
359
361
|
// A person's AI on its own cloud machine (routes/ai-vm.js): off (503) without Fly set up.
|
|
360
|
-
const aiVm = aiVmRoutes({ ...ctx, relayUrl, fly, waitMs: aiVmWaitMs })
|
|
362
|
+
const aiVm = aiVmRoutes({ ...ctx, relayUrl, fly, waitMs: aiVmWaitMs, spareSuspendMs: aiVmSpareSuspendMs })
|
|
361
363
|
routes.push(...aiVm.routes)
|
|
362
364
|
// A team's git rule or who is on a team changed: the relay asks its sessions' rules again now (relay /github/policy).
|
|
363
|
-
|
|
365
|
+
// With a room (a session put in or taken out of a workspace), only that session asks.
|
|
366
|
+
const policyChanged = (room = '') => {
|
|
364
367
|
if (!relay || !relaySecret) return
|
|
365
|
-
cluster.tellRelays('/github/policy', {}).catch((err) => log(`git rules to relay: ${err.message}`))
|
|
368
|
+
cluster.tellRelays('/github/policy', room ? { room } : {}).catch((err) => log(`git rules to relay: ${err.message}`))
|
|
366
369
|
}
|
|
367
|
-
routes.push(...orgRoutes(ctx), ...memberRoutes(ctx), ...teamRoutes({ ...ctx, policyChanged }), ...inviteRoutes(ctx), ...agentRoutes(ctx), ...agentInviteRoutes({ ...ctx, workspaces }), ...joinRoutes(ctx), ...relayRoutes(ctx), ...githubRoutes({ ...ctx, github, githubFetch, relayHttp: relay, relayFetch: githubFetch, tellRelays: (path, body) => cluster.tellRelays(path, body), hookSecret, workspaces }), ...relayClusterRoutes({ ...ctx, cluster }), ...sessionRoutes(ctx), ...accessTypeRoutes(ctx), ...grantRoutes({ ...ctx, workspaces }), ...sessionInviteRoutes({ ...ctx, workspaces }), ...issueRoutes(ctx))
|
|
370
|
+
routes.push(...orgRoutes(ctx), ...memberRoutes(ctx), ...teamRoutes({ ...ctx, policyChanged }), ...inviteRoutes(ctx), ...agentRoutes({ ...ctx, passKey }), ...agentInviteRoutes({ ...ctx, workspaces }), ...joinRoutes(ctx), ...relayRoutes(ctx), ...githubRoutes({ ...ctx, github, githubFetch, relayHttp: relay, relayFetch: githubFetch, tellRelays: (path, body) => cluster.tellRelays(path, body), hookSecret, workspaces }), ...relayClusterRoutes({ ...ctx, cluster }), ...sessionRoutes(ctx), ...accessTypeRoutes(ctx), ...grantRoutes({ ...ctx, workspaces }), ...sessionInviteRoutes({ ...ctx, workspaces }), ...issueRoutes(ctx))
|
|
368
371
|
// Always routed: with the flag off each answers a plain 404 of its own, so the app's
|
|
369
372
|
// check at every launch isn't filed as a missing route.
|
|
370
373
|
// Only these take a hosted agent's pass (workspaceReach), so they alone get the pass key.
|
|
371
|
-
routes.push(...workspaceRoutes({ ...ctx, workspaces, passKey }), ...workspaceInviteRoutes({ ...ctx, workspaces, passKey }), ...workspaceAgentRoutes({ ...ctx, workspaces, passKey, relayUrl, webhookFetch, webhookLookup, allowLocalWebhooks, trackDelivery, limitAnnounce }))
|
|
374
|
+
routes.push(...workspaceRoutes({ ...ctx, workspaces, passKey, policyChanged }), ...workspaceInviteRoutes({ ...ctx, workspaces, passKey }), ...workspaceAgentRoutes({ ...ctx, workspaces, passKey, relayUrl, webhookFetch, webhookLookup, allowLocalWebhooks, trackDelivery, limitAnnounce }))
|
|
372
375
|
const wsFiles = workspaceFileRoutes({ ...ctx, workspaces, passKey })
|
|
373
376
|
routes.push(...wsFiles.routes)
|
|
377
|
+
// Secrets (routes/secrets.js): off until the vault is set up (QUILT_VAULT_SECRET).
|
|
378
|
+
routes.push(...secretRoutes({ ...ctx, passKey, vaultSecret, vaultUrl, allowLocalUpstreams, ...(secretWaitMs ? { waitMs: secretWaitMs } : {}) }))
|
|
374
379
|
|
|
375
380
|
async function openLink (code) {
|
|
376
381
|
const userCode = normalizeUserCode(code)
|
|
@@ -427,6 +432,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
427
432
|
try {
|
|
428
433
|
if (pathname.startsWith('/v1/file-data/') && files instanceof DiskStore) return await serveFileData(req, res, send, pathname.slice('/v1/file-data/'.length))
|
|
429
434
|
if (pathname === '/mcp') return await proxyMcp(req, res, send)
|
|
435
|
+
if (req.method === 'GET' && (pathname === '/v1/releases/latest' || pathname.startsWith('/v1/download/'))) return await serveRelease(req, res, send, pathname)
|
|
430
436
|
const url = new URL(req.url, 'http://x')
|
|
431
437
|
const route = routes.find(([m, re]) => m === req.method && re.test(url.pathname))
|
|
432
438
|
if (!route) throw Object.assign(new HttpError(404, 'not found'), { noRoute: true })
|
|
@@ -452,6 +458,29 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
452
458
|
}
|
|
453
459
|
})
|
|
454
460
|
|
|
461
|
+
// Downloads and the update check (releases.js): public, from the private repository's latest
|
|
462
|
+
// release, read as Quilt's GitHub App. A download is a redirect to GitHub's own short-lived link.
|
|
463
|
+
const releases = releaseSource({ github, fetch: githubFetch, now, apiUrl: api })
|
|
464
|
+
async function serveRelease (req, res, send, pathname) {
|
|
465
|
+
if (!releases) throw new HttpError(503, 'downloads are not set up on this server')
|
|
466
|
+
try {
|
|
467
|
+
if (pathname === '/v1/releases/latest') {
|
|
468
|
+
const body = JSON.stringify(await releases.latestPublic())
|
|
469
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'public, max-age=60', ...cors(req) })
|
|
470
|
+
return res.end(body)
|
|
471
|
+
}
|
|
472
|
+
let rest = ''
|
|
473
|
+
try { rest = decodeURIComponent(pathname.slice('/v1/download/'.length)) } catch {}
|
|
474
|
+
const [first, second] = rest.split('/')
|
|
475
|
+
const to = second !== undefined ? await releases.downloadUrl(second, first) : await releases.downloadUrl(first)
|
|
476
|
+
res.writeHead(302, { location: to, 'cache-control': 'no-store', ...cors(req) })
|
|
477
|
+
return res.end()
|
|
478
|
+
} catch (err) {
|
|
479
|
+
if (err && err.status && !(err instanceof HttpError)) throw new HttpError(err.status, err.message)
|
|
480
|
+
throw err
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
455
484
|
// Hosted agents' MCP: the agent's access key signs it in here; the relay gets the same
|
|
456
485
|
// request with a pass for the agent, and its answer comes straight back. Stateless on
|
|
457
486
|
// both sides, so each request stands alone. The pass is for the room the agent is joining
|
|
@@ -573,6 +602,8 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
573
602
|
// Workspace files: uploads that never landed, and deleted files past their 30 days
|
|
574
603
|
// (only with the flag on: before then the tables may not exist yet).
|
|
575
604
|
if (workspaces) try { await wsFiles.sweep() } catch (err) { log(`file sweep failed: ${err?.message || err}`) }
|
|
605
|
+
// Secrets' write tickets a day past their end (only with secrets on: the tables exist then).
|
|
606
|
+
if (vaultSecret) try { await store.pruneSecretTickets(now() - 24 * 60 * 60 * 1000) } catch (err) { log(`secret ticket prune failed: ${err?.message || err}`) }
|
|
576
607
|
}
|
|
577
608
|
const prune = setInterval(() => { pruneNow() }, pruneEveryMs)
|
|
578
609
|
prune.unref()
|
|
@@ -587,7 +618,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
587
618
|
|
|
588
619
|
return new Promise((resolve) => server.listen(port, host, () => {
|
|
589
620
|
const p = server.address().port
|
|
590
|
-
resolve({ port: p, url: `http://${host}:${p}`, cluster, close: () => { clearInterval(prune); clearTimeout(firstPrune); return new Promise((r) => server.close(r)) }, startKeys: () => limitStarts.size(), sweepFiles: (at) => wsFiles.sweep(at), flushWebhooks, githubReady })
|
|
621
|
+
resolve({ port: p, url: `http://${host}:${p}`, cluster, fillSpares: aiVm.fillSpares, close: () => { clearInterval(prune); clearTimeout(firstPrune); aiVm.close(); return new Promise((r) => server.close(r)) }, startKeys: () => limitStarts.size(), sweepFiles: (at) => wsFiles.sweep(at), flushWebhooks, githubReady })
|
|
591
622
|
}))
|
|
592
623
|
}
|
|
593
624
|
|
|
@@ -23,7 +23,7 @@ const TEAM_MEMBER = 'team_id, member_id, access, scopes, added_at'
|
|
|
23
23
|
const INVITE = 'id, org_id, email, role_id, token_hash, invited_by, expires_at, accepted_at, cancelled_at, created_at'
|
|
24
24
|
const REQUEST = 'id, org_id, user_id, email, status, decided_by, decided_at, created_at'
|
|
25
25
|
const AGENT_INVITE = 'id, token_hash, owner_user_id, org_id, created_by, role_id, teams, expires_at, used_at, used_by_agent_id, rejoined, cancelled_at, created_at, workspace_id, workspace_access, workspace_sessions, global'
|
|
26
|
-
const AGENT_APP_KEY = 'id, agent_id, name, key_hash, created_at, last_used_at, revoked_at'
|
|
26
|
+
const AGENT_APP_KEY = 'id, agent_id, name, key_hash, created_at, last_used_at, revoked_at, kind, hint, room, session_link, session_name, slot'
|
|
27
27
|
const AGENT_KEY = 'id, agent_id, family_id, access_hash, refresh_hash, access_expires_at, refresh_expires_at, refreshed_at, revoked_at, created_at'
|
|
28
28
|
const RELAY_SESSION = 'room, name, owner_account, created_at, last_active_at, renamed_at, workspace_id, workspace_linked_by'
|
|
29
29
|
const ACCESS_TYPE = 'id, owner_account, name, files, folders, talk, created_at, updated_at'
|
|
@@ -38,10 +38,18 @@ const AGENT_PLACEMENT = 'agent_id, reach, workspace_ids, sessions, access, scope
|
|
|
38
38
|
const WORKSPACE_AGENT_OVERRIDE = 'workspace_id, agent_id, sessions, excluded'
|
|
39
39
|
const SESSION_AGENT_EXCLUSION = 'room, agent_id, excluded_by, created_at'
|
|
40
40
|
const AGENT_WEBHOOK = 'agent_id, url, secret, created_at, updated_at'
|
|
41
|
-
const AI_VM = 'user_id, tool, model, prompt, cap, agent_id, invite_id, machine_id, volume_id, region, state, error, signed_in, usage_today, last_report_at, created_at, updated_at'
|
|
42
|
-
const RELAY_MACHINE = 'id, region, capacity, state, dedicated_room, load, hello_at, reported_at, created_at'
|
|
43
|
-
const RELAY_ROOM = 'room, machine_id, state, stats, busy_minutes, heavy, placed_at, reported_at'
|
|
41
|
+
const AI_VM = 'user_id, tool, model, prompt, cap, agent_id, invite_id, machine_id, volume_id, region, state, error, signed_in, usage_today, sessions, last_report_at, created_at, updated_at'
|
|
42
|
+
const RELAY_MACHINE = 'id, region, capacity, state, dedicated_room, load, hot_at, over_at, empty_at, hello_at, reported_at, created_at'
|
|
43
|
+
const RELAY_ROOM = 'room, machine_id, state, stats, busy_minutes, heavy, placed_at, reported_at, moved_at'
|
|
44
44
|
const RELAY_JOB = 'key, state, detail, started_at, finished_at'
|
|
45
|
+
// Secrets: named columns, so a list never carries a sealed value (secretSealed reads that, for the vault).
|
|
46
|
+
const SECRET = 'id, scope_type, scope_id, name, key_version, digest, upstream, format, who, created_by, updated_by, created_at, updated_at, last_used_at'
|
|
47
|
+
const SECRET_DATA_KEY = 'scope_type, scope_id, version, provider, sealed_key, created_at'
|
|
48
|
+
const SECRET_SETTINGS = 'scope_type, scope_id, inherit_off, inherit_off_by, inherit_off_at, agents_on, agents, updated_at'
|
|
49
|
+
const SECRET_TICKET = 'id, token_hash, scope_type, scope_id, entries, created_by, created_at, expires_at, redeemed_at, stored_at'
|
|
50
|
+
const SECRET_RUN = 'id, token_hash, room, account, kind, label, created_at, expires_at, revoked_at'
|
|
51
|
+
const SECRET_ASK = 'run_id, secret_id, name, state, asked_at, answered_at'
|
|
52
|
+
const SECRET_USE = 'id, secret_id, run_id, room, account, upstream, at'
|
|
45
53
|
// PostgREST hands back at most 1000 rows per request: longer lists are read a page at a time.
|
|
46
54
|
const PAGE = 1000
|
|
47
55
|
|
|
@@ -168,8 +176,26 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
|
|
|
168
176
|
},
|
|
169
177
|
|
|
170
178
|
// App keys (qk_): hashes only, and they don't run out.
|
|
171
|
-
|
|
172
|
-
|
|
179
|
+
// A sandbox key (kind 'sandbox') also has its session, slot and hint; a plain app key has none.
|
|
180
|
+
async createAgentAppKey ({ agentId, name, keyHash, kind, hint, room, sessionLink, sessionName, slot }) {
|
|
181
|
+
const insert = { agent_id: agentId, name, key_hash: keyHash, ...toSnake({ kind, hint, room, sessionLink, sessionName, slot }) }
|
|
182
|
+
return rowFrom(await one(db.from('agent_app_keys').insert(insert).select(AGENT_APP_KEY).single()))
|
|
183
|
+
},
|
|
184
|
+
async agentAppKeyById (id) { return rowFrom(await one(db.from('agent_app_keys').select(AGENT_APP_KEY).eq('id', id).maybeSingle())) },
|
|
185
|
+
// These agents' sandbox keys, revoked ones too (until removed), newest first.
|
|
186
|
+
async listSandboxKeys (agentIds) {
|
|
187
|
+
if (!agentIds.length) return []
|
|
188
|
+
return (await one(db.from('agent_app_keys').select(AGENT_APP_KEY).in('agent_id', agentIds).eq('kind', 'sandbox').order('created_at', { ascending: false }))).map(rowFrom)
|
|
189
|
+
},
|
|
190
|
+
// Only a revoked key, and only one of this agent's.
|
|
191
|
+
async removeAgentAppKey (agentId, id) {
|
|
192
|
+
const rows = await one(db.from('agent_app_keys').delete().eq('id', id).eq('agent_id', agentId).not('revoked_at', 'is', null).select('id'))
|
|
193
|
+
return rows.length > 0
|
|
194
|
+
},
|
|
195
|
+
async countAppKeyUse (keyId, day) { await one(db.rpc('count_app_key_use', { p_key: keyId, p_day: day })) },
|
|
196
|
+
async appKeyUses (keyIds, sinceDay) {
|
|
197
|
+
if (!keyIds.length) return []
|
|
198
|
+
return (await one(db.from('agent_app_key_uses').select('key_id, day, count').in('key_id', keyIds).gte('day', sinceDay))).map((r) => ({ keyId: r.key_id, day: r.day, count: r.count }))
|
|
173
199
|
},
|
|
174
200
|
async agentAppKeyByHash (h) { return rowFrom(await one(db.from('agent_app_keys').select(AGENT_APP_KEY).eq('key_hash', h).maybeSingle())) },
|
|
175
201
|
async listAgentAppKeys (agentId) {
|
|
@@ -279,11 +305,11 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
|
|
|
279
305
|
return r ? r.value : String(value)
|
|
280
306
|
},
|
|
281
307
|
|
|
282
|
-
// Relay machines and where each room lives (
|
|
308
|
+
// Relay machines and where each room lives (20261017100000_relay_cluster.sql, 20261018100000_relay_autoscale.sql; src/api/relay-cluster.js).
|
|
283
309
|
async relayMachine (id) { return rowFrom(await one(db.from('relay_machines').select(RELAY_MACHINE).eq('id', id).maybeSingle())) },
|
|
284
310
|
async relayMachines () { return (await one(db.from('relay_machines').select(RELAY_MACHINE).order('created_at').order('id'))).map(rowFrom) },
|
|
285
|
-
async putRelayMachine ({ id, helloAt, reportedAt, ...fields }) {
|
|
286
|
-
const row = toSnake({ id, ...fields, hello_at: ts(helloAt), reported_at: ts(reportedAt) })
|
|
311
|
+
async putRelayMachine ({ id, helloAt, reportedAt, hotAt, overAt, emptyAt, ...fields }) {
|
|
312
|
+
const row = toSnake({ id, ...fields, hello_at: ts(helloAt), reported_at: ts(reportedAt), hot_at: ts(hotAt), over_at: ts(overAt), empty_at: ts(emptyAt) })
|
|
287
313
|
return rowFrom(await one(db.from('relay_machines').upsert(row, { onConflict: 'id' }).select(RELAY_MACHINE).single()))
|
|
288
314
|
},
|
|
289
315
|
async relayRoom (room) { return rowFrom(await one(db.from('relay_rooms').select(RELAY_ROOM).eq('room', room).maybeSingle())) },
|
|
@@ -294,8 +320,8 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
|
|
|
294
320
|
return out
|
|
295
321
|
},
|
|
296
322
|
async placeRelayRoom (room, machineId) { return rowFrom((await one(db.rpc('place_relay_room', { p_room: room, p_machine: machineId })))[0]) },
|
|
297
|
-
async setRelayRoom (room, { reportedAt, ...patch }) {
|
|
298
|
-
return rowFrom(await one(db.from('relay_rooms').update(toSnake({ ...patch, reported_at: ts(reportedAt) })).eq('room', room).select(RELAY_ROOM).maybeSingle()))
|
|
323
|
+
async setRelayRoom (room, { reportedAt, movedAt, ...patch }) {
|
|
324
|
+
return rowFrom(await one(db.from('relay_rooms').update(toSnake({ ...patch, reported_at: ts(reportedAt), moved_at: ts(movedAt) })).eq('room', room).select(RELAY_ROOM).maybeSingle()))
|
|
299
325
|
},
|
|
300
326
|
async forgetRelayRoom (room, machineId) {
|
|
301
327
|
let q = db.from('relay_rooms').delete().eq('room', room)
|
|
@@ -760,6 +786,90 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
|
|
|
760
786
|
async pruneIssues (before) {
|
|
761
787
|
await one(db.from('issues').delete().lt('last_seen_at', ts(before)))
|
|
762
788
|
return 0
|
|
789
|
+
},
|
|
790
|
+
|
|
791
|
+
// Secrets (see 20261018000000_secrets.sql).
|
|
792
|
+
async listSecrets (scopeType, scopeId) { return (await one(db.from('secrets').select(SECRET).eq('scope_type', scopeType).eq('scope_id', scopeId).order('name'))).map(rowFrom) },
|
|
793
|
+
async secretByName (scopeType, scopeId, name) { return rowFrom(await one(db.from('secrets').select(SECRET).eq('scope_type', scopeType).eq('scope_id', scopeId).eq('name', name).maybeSingle())) },
|
|
794
|
+
async secretById (id) { return rowFrom(await one(db.from('secrets').select(SECRET).eq('id', id).maybeSingle())) },
|
|
795
|
+
async secretSealed (id) {
|
|
796
|
+
const r = await one(db.from('secrets').select('sealed_value, key_version').eq('id', id).maybeSingle())
|
|
797
|
+
return r ? { sealedValue: r.sealed_value, keyVersion: r.key_version } : null
|
|
798
|
+
},
|
|
799
|
+
async putSecret ({ scopeType, scopeId, name, sealedValue, keyVersion, digest, upstream, format, who, by }) {
|
|
800
|
+
const set = { sealed_value: sealedValue, key_version: keyVersion, digest, upstream, format, who, updated_by: by, updated_at: new Date().toISOString() }
|
|
801
|
+
const had = await one(db.from('secrets').update(set).eq('scope_type', scopeType).eq('scope_id', scopeId).eq('name', name).select(SECRET))
|
|
802
|
+
if (had.length) return rowFrom(had[0])
|
|
803
|
+
return rowFrom(await one(db.from('secrets').insert({ scope_type: scopeType, scope_id: scopeId, name, ...set, created_by: by }).select(SECRET).single()))
|
|
804
|
+
},
|
|
805
|
+
async updateSecret (id, { who, updatedBy }) {
|
|
806
|
+
return rowFrom(await one(db.from('secrets').update({ who, updated_by: updatedBy, updated_at: new Date().toISOString() }).eq('id', id).select(SECRET).maybeSingle()))
|
|
807
|
+
},
|
|
808
|
+
async deleteSecret (id) { return (await one(db.from('secrets').delete().eq('id', id).select('id'))).length > 0 },
|
|
809
|
+
async deleteScopeSecrets (scopeType, scopeId) {
|
|
810
|
+
await one(db.from('secrets').delete().eq('scope_type', scopeType).eq('scope_id', scopeId))
|
|
811
|
+
await one(db.from('secret_data_keys').delete().eq('scope_type', scopeType).eq('scope_id', scopeId))
|
|
812
|
+
await one(db.from('secret_scope_settings').delete().eq('scope_type', scopeType).eq('scope_id', scopeId))
|
|
813
|
+
},
|
|
814
|
+
async secretDataKey (scopeType, scopeId) {
|
|
815
|
+
return rowFrom(await one(db.from('secret_data_keys').select(SECRET_DATA_KEY).eq('scope_type', scopeType).eq('scope_id', scopeId).order('version', { ascending: false }).limit(1).maybeSingle()))
|
|
816
|
+
},
|
|
817
|
+
async secretDataKeyVersion (scopeType, scopeId, version) {
|
|
818
|
+
return rowFrom(await one(db.from('secret_data_keys').select(SECRET_DATA_KEY).eq('scope_type', scopeType).eq('scope_id', scopeId).eq('version', version).maybeSingle()))
|
|
819
|
+
},
|
|
820
|
+
async putSecretDataKey ({ scopeType, scopeId, version, sealedKey, provider }) {
|
|
821
|
+
await one(db.from('secret_data_keys').upsert({ scope_type: scopeType, scope_id: scopeId, version, sealed_key: sealedKey, provider }, { onConflict: 'scope_type,scope_id,version', ignoreDuplicates: true }))
|
|
822
|
+
return rowFrom(await one(db.from('secret_data_keys').select(SECRET_DATA_KEY).eq('scope_type', scopeType).eq('scope_id', scopeId).eq('version', version).single()))
|
|
823
|
+
},
|
|
824
|
+
async secretSettings (scopeType, scopeId) {
|
|
825
|
+
const r = rowFrom(await one(db.from('secret_scope_settings').select(SECRET_SETTINGS).eq('scope_type', scopeType).eq('scope_id', scopeId).maybeSingle()))
|
|
826
|
+
return r || { scopeType, scopeId, inheritOff: false, inheritOffBy: null, inheritOffAt: null, agentsOn: false, agents: 'all', updatedAt: null }
|
|
827
|
+
},
|
|
828
|
+
async putSecretSettings (scopeType, scopeId, patch) {
|
|
829
|
+
const set = toSnake({ ...patch, inheritOffAt: ts(patch.inheritOffAt) })
|
|
830
|
+
return rowFrom(await one(db.from('secret_scope_settings').upsert({ scope_type: scopeType, scope_id: scopeId, ...set, updated_at: new Date().toISOString() }, { onConflict: 'scope_type,scope_id' }).select(SECRET_SETTINGS).single()))
|
|
831
|
+
},
|
|
832
|
+
async createSecretTicket ({ tokenHash, scopeType, scopeId, entries, createdBy, expiresAt }) {
|
|
833
|
+
return rowFrom(await one(db.from('secret_tickets').insert({ token_hash: tokenHash, scope_type: scopeType, scope_id: scopeId, entries, created_by: createdBy, expires_at: ts(expiresAt) }).select(SECRET_TICKET).single()))
|
|
834
|
+
},
|
|
835
|
+
async redeemSecretTicket (tokenHash, at) {
|
|
836
|
+
const rows = await one(db.rpc('redeem_secret_ticket', { p_token_hash: tokenHash, p_at: ts(at) }))
|
|
837
|
+
return rowFrom((rows || [])[0] || null)
|
|
838
|
+
},
|
|
839
|
+
async secretTicketById (id) { return rowFrom(await one(db.from('secret_tickets').select(SECRET_TICKET).eq('id', id).maybeSingle())) },
|
|
840
|
+
async markSecretTicketStored (id, at) {
|
|
841
|
+
return (await one(db.from('secret_tickets').update({ stored_at: ts(at) }).eq('id', id).not('redeemed_at', 'is', null).is('stored_at', null).select('id'))).length > 0
|
|
842
|
+
},
|
|
843
|
+
async pruneSecretTickets (before) { await one(db.from('secret_tickets').delete().lt('expires_at', ts(before))) },
|
|
844
|
+
async createSecretRun ({ tokenHash, room, account, kind, label = '', expiresAt }) {
|
|
845
|
+
return rowFrom(await one(db.from('secret_runs').insert({ token_hash: tokenHash, room, account, kind, label, expires_at: ts(expiresAt) }).select(SECRET_RUN).single()))
|
|
846
|
+
},
|
|
847
|
+
async secretRunByToken (tokenHash) { return rowFrom(await one(db.from('secret_runs').select(SECRET_RUN).eq('token_hash', tokenHash).maybeSingle())) },
|
|
848
|
+
async secretRunById (id) { return rowFrom(await one(db.from('secret_runs').select(SECRET_RUN).eq('id', id).maybeSingle())) },
|
|
849
|
+
async revokeSecretRun (id, at) {
|
|
850
|
+
return (await one(db.from('secret_runs').update({ revoked_at: ts(at) }).eq('id', id).is('revoked_at', null).select('id'))).length > 0
|
|
851
|
+
},
|
|
852
|
+
async putSecretRunAsks (runId, asks) {
|
|
853
|
+
if (!asks.length) return
|
|
854
|
+
await one(db.from('secret_run_asks').insert(asks.map((a) => ({ run_id: runId, secret_id: a.secretId, name: a.name, state: a.state || 'asked' }))))
|
|
855
|
+
},
|
|
856
|
+
async listSecretRunAsks (runId) { return (await one(db.from('secret_run_asks').select(SECRET_ASK).eq('run_id', runId))).map(rowFrom) },
|
|
857
|
+
async answerSecretRunAsks (runId, state, at) {
|
|
858
|
+
return (await one(db.from('secret_run_asks').update({ state, answered_at: ts(at) }).eq('run_id', runId).eq('state', 'asked').select('run_id'))).length
|
|
859
|
+
},
|
|
860
|
+
async pendingSecretAsks (account, at) {
|
|
861
|
+
const runs = (await one(db.from('secret_runs').select(SECRET_RUN).eq('account', account).is('revoked_at', null).gt('expires_at', ts(at)).order('created_at'))).map(rowFrom)
|
|
862
|
+
if (!runs.length) return []
|
|
863
|
+
const asks = (await one(db.from('secret_run_asks').select(SECRET_ASK).in('run_id', runs.map((r) => r.id)).eq('state', 'asked'))).map(rowFrom)
|
|
864
|
+
return runs.map((run) => ({ run, asks: asks.filter((a) => a.runId === run.id) })).filter((x) => x.asks.length)
|
|
865
|
+
},
|
|
866
|
+
async recordSecretUse ({ secretId, runId, room, account, upstream, at }) {
|
|
867
|
+
await one(db.from('secret_uses').insert({ secret_id: secretId, run_id: runId, room, account, upstream, at: ts(at) }))
|
|
868
|
+
await one(db.from('secrets').update({ last_used_at: ts(at) }).eq('id', secretId))
|
|
869
|
+
},
|
|
870
|
+
async listSecretUses (secretIds, { limit = 100 } = {}) {
|
|
871
|
+
if (!secretIds.length) return []
|
|
872
|
+
return (await one(db.from('secret_uses').select(SECRET_USE).in('secret_id', secretIds).order('at', { ascending: false }).limit(limit))).map((r) => ({ ...rowFrom(r), at: ms(r.at) }))
|
|
763
873
|
}
|
|
764
874
|
}
|
|
765
875
|
}
|
package/src/autowork.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// "Work on its own": when work comes to "<person>'s AI" (a task given to it, a
|
|
1
|
+
// "Work on its own": when work comes to "<person>'s AI" (a task given to it, a message to
|
|
2
2
|
// it) and none of the person's AI sessions is live, Quilt can start the person's own AI tool
|
|
3
3
|
// in its non-interactive mode, on their computer, in the session's folder — only when they
|
|
4
4
|
// turned it on in Settings. The run talks through Quilt chat: it asks with quilt_message and
|
|
@@ -71,8 +71,6 @@ export function runPrompt ({ person, job, changeFiles, prompt = '' }) {
|
|
|
71
71
|
if (job.kind === 'task') {
|
|
72
72
|
lines.push(`${job.by || 'Someone'} gave ${person}'s AI a task on the board:`, `Task ${job.id}: ${job.title}`, '',
|
|
73
73
|
'Read it in full with quilt_task, move it to "doing" with quilt_move_task when you start (that briefs you), and to "qa" with qaNotes when it is done and checked.')
|
|
74
|
-
} else if (job.kind === 'merge') {
|
|
75
|
-
lines.push(job.prompt.trim())
|
|
76
74
|
} else if (job.kind === 'message') {
|
|
77
75
|
lines.push(`${job.by || 'Someone'} wrote to ${person}'s AI in Quilt chat:`, '', String(job.text || '').trim(), '',
|
|
78
76
|
`Answer ${job.by || 'them'} with quilt_message. If they ask for work, do it as ${person}'s AI would, and say in chat what you did.`)
|
|
@@ -211,14 +209,6 @@ export class AutoWork {
|
|
|
211
209
|
}
|
|
212
210
|
}
|
|
213
211
|
|
|
214
|
-
/** A merge sent to the person's AI. Resolves once it is queued; the run follows. */
|
|
215
|
-
sendMerge (session, { id, path: file, prompt }) {
|
|
216
|
-
const c = this.config()
|
|
217
|
-
if (!c.preset) throw new Error(`${c.tool || 'Your AI tool'} can't work on its own: open the merge in it instead`)
|
|
218
|
-
this.enqueue({ kind: 'merge', session, id, path: file, title: `merge ${file}`, prompt, by: session.name })
|
|
219
|
-
return { queued: true, tool: c.tool }
|
|
220
|
-
}
|
|
221
|
-
|
|
222
212
|
enqueue (job) {
|
|
223
213
|
this.queue.push(job)
|
|
224
214
|
this.idle = this.idle.then(() => this.next()).catch(() => {})
|
|
@@ -262,7 +252,7 @@ export class AutoWork {
|
|
|
262
252
|
const args = resumes
|
|
263
253
|
? p.resume({ ...o, prompt: replyPrompt({ run, reply: job.reply, resumes: true }) })
|
|
264
254
|
: p.start({ ...o, prompt: job.kind === 'reply' ? replyPrompt({ run, reply: job.reply, resumes: false }) : run.prompt })
|
|
265
|
-
const label =
|
|
255
|
+
const label = run.title || 'work'
|
|
266
256
|
const started = this.now()
|
|
267
257
|
let persona = null
|
|
268
258
|
const r = await this.spawnRun(exe, args, {
|
|
@@ -296,15 +286,13 @@ export class AutoWork {
|
|
|
296
286
|
}
|
|
297
287
|
|
|
298
288
|
/**
|
|
299
|
-
* One plain line in chat for the person; the work stays where it was
|
|
300
|
-
* file again). A tool that isn't signed in is said so plainly, never with one tool's fix.
|
|
289
|
+
* One plain line in chat for the person; the work stays where it was. A tool that isn't signed in is said so plainly, never with one tool's fix.
|
|
301
290
|
*/
|
|
302
291
|
fail (session, run, why) {
|
|
303
292
|
const where = session.aiOf ? 'your cloud AI (Settings → Your AI)' : 'this computer'
|
|
304
293
|
const text = SIGNED_OUT.test(String(why)) ? `${run.tool || 'your AI tool'} isn't signed in on ${where}` : String(why).replace(/[.\s]+$/, '').slice(0, 300)
|
|
305
294
|
this.notice(session, `Couldn't run your AI for ${run.title || 'that work'}: ${text}. It's still waiting for your AI in your chat.`, run)
|
|
306
295
|
session.dropAutoPersona(run.via)
|
|
307
|
-
if (run.kind === 'merge' && run.path) session.releaseMergeClaim(run.path).catch(() => {})
|
|
308
296
|
}
|
|
309
297
|
|
|
310
298
|
notice (session, text, run = null) {
|
package/src/catchup.js
CHANGED
|
@@ -104,7 +104,7 @@ export function catchUpMarkdown (c, { ago = () => '' } = {}) {
|
|
|
104
104
|
const m = c.mine || {}
|
|
105
105
|
if (m.shared) out.push(`- Your ${plural(m.shared, 'change')} from while you were away went into the session.`)
|
|
106
106
|
if (m.merged?.length) out.push(`- Combined with the session's changes: ${m.merged.map((p) => `\`${p}\``).join(', ')}.`)
|
|
107
|
-
if (m.conflicts?.length) out.push(`-
|
|
107
|
+
if (m.conflicts?.length) out.push(`- Held by someone else, so waiting: Quilt merges your changes in when they let go of ${m.conflicts.map((p) => `\`${p}\``).join(', ')}.`)
|
|
108
108
|
if (c.backups?.length) out.push(`- Your copies of ${plural(c.backups.length, 'file')} differed from the session's and were replaced; they are kept: ${c.backups.map((b) => `\`${b.path}\` → \`${b.copy}\``).join(', ')}.`)
|
|
109
109
|
out.push('Read quilt_history for the diffs before you change these files.')
|
|
110
110
|
return out
|
package/src/clash.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
5
6
|
// presence; only the owner's Quilt writes the task, under an id derived from
|
|
6
7
|
// the branch and the upstream commit, so two that race write the same task.
|
|
7
8
|
// Pure: Session (reviewClash) reads presence and the board and applies these.
|
|
@@ -106,35 +107,22 @@ export function clashFiles (conflicts = []) {
|
|
|
106
107
|
return [...new Set(conflicts.map((c) => c.path))].slice(0, MAX_FILES)
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
/** The task's description, as its first comment: each file and why
|
|
110
|
+
/** The task's description, as its first comment: each file and why. Nobody is asked to pull or merge. */
|
|
110
111
|
export function clashBrief ({ branch, upstream, sha, behind, conflicts = [] }) {
|
|
111
|
-
const lines = [`${n(behind, 'commit')} on ${upstream} (up to ${sha.slice(0, 7)})
|
|
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:`]
|
|
112
113
|
for (const c of conflicts.slice(0, 30)) lines.push(`- ${c.path}: ${c.why}`)
|
|
113
114
|
if (conflicts.length > 30) lines.push(`- and ${conflicts.length - 30} more`)
|
|
114
|
-
lines.push('', `
|
|
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.`)
|
|
115
116
|
return lines.join('\n')
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
/** The notice for a member whose folder is behind but who is not merging: leave it to the owner. */
|
|
119
120
|
export function leaveItNotice ({ label, upstream, task, facts }) {
|
|
120
|
-
return `${label} is
|
|
121
|
+
return `${label} is bringing in the commits from ${upstream} (task ${task}); leave those files to them.${facts ? ` ${facts}` : ''}`
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
/** What a folder that reports the clash knows about it, without the advice to pull. */
|
|
124
125
|
export function clashFacts ({ branch, upstream, behind, conflicts = [] }) {
|
|
125
126
|
const list = conflicts.slice(0, 4).map((c) => `${c.path} (${c.why})`).join('; ') + (conflicts.length > 4 ? `; and ${conflicts.length - 4} more` : '')
|
|
126
|
-
return `${branch} is ${n(behind, 'commit')} behind ${upstream}, and
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* The description of a clash the relay hands to a hosted agent (relay-upstream.js): no folder on
|
|
131
|
-
* the branch is online, so the agent merges in the session itself, file by file. The change from
|
|
132
|
-
* upstream for each file follows in the next comments.
|
|
133
|
-
*/
|
|
134
|
-
export function relayClashBrief ({ branch, upstream, sha, base, behind, conflicts = [], compare = '' }) {
|
|
135
|
-
const lines = [`${n(behind, 'commit')} on ${upstream} (${base.slice(0, 7)} → ${sha.slice(0, 7)}) clash with the session's uncommitted work on \`${branch}\`, and no folder on \`${branch}\` that can bring commits in is online, so the relay brought nothing in:`]
|
|
136
|
-
for (const c of conflicts.slice(0, 20)) lines.push(`- ${c.path}: ${c.why}`)
|
|
137
|
-
if (conflicts.length > 20) lines.push(`- and ${conflicts.length - 20} more`)
|
|
138
|
-
lines.push('', `For each file: read it with quilt_read_file, fold in what ${upstream} changed (the next comments${compare ? `, or ${compare}` : ''}) while keeping the session's work, and write the merged file (quilt_write_file, or in your folder), without conflict markers. Each write of one of these files makes the relay look again at once: a file counts as merged when the lines that clashed were rewritten. If you merged a file differently (kept one side whole, say), move this task to QA when every file is done: that tells the relay the files are merged as they are. The rest of the commits then come in, and Quilt closes this task by itself. quilt_sync_branch asks the relay to look again too.`)
|
|
139
|
-
return lines.join('\n').slice(0, 2000)
|
|
127
|
+
return `${branch} is ${n(behind, 'commit')} behind ${upstream}, and Quilt can't read ${list} to bring them in.`
|
|
140
128
|
}
|
package/src/control.js
CHANGED
|
@@ -6,9 +6,15 @@ import path from 'node:path'
|
|
|
6
6
|
import crypto from 'node:crypto'
|
|
7
7
|
import { renderStatus } from './status.js'
|
|
8
8
|
import { migrateDir } from './legacy.js'
|
|
9
|
+
import { createRun, waitRun, endRun, secretNames } from './secrets-client.js'
|
|
9
10
|
|
|
10
11
|
export async function startControl (session, extras = {}) {
|
|
11
12
|
const token = crypto.randomBytes(16).toString('hex')
|
|
13
|
+
// { api, token } for the accounts API as this session's identity (runner.js `apiAuth`).
|
|
14
|
+
const secretsApi = async () => {
|
|
15
|
+
if (!extras.apiAuth) throw new Error('This session is not signed in to Quilt, so it has no secrets. Sign in (quilt login) and join again.')
|
|
16
|
+
return extras.apiAuth()
|
|
17
|
+
}
|
|
12
18
|
const routes = {
|
|
13
19
|
'GET /status': (b) => {
|
|
14
20
|
// To one of the AI sessions working through this app, "you" is that session (persona.js).
|
|
@@ -143,22 +149,17 @@ export async function startControl (session, extras = {}) {
|
|
|
143
149
|
return r
|
|
144
150
|
},
|
|
145
151
|
'POST /tasks/delete': (b) => { session.deleteTask(b.id); return { tasks: session.taskList() } },
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// Agents are told the other version is under .quilt/merges/<id>/, so put
|
|
149
|
-
// it there on this machine too (base and ours are written once). A local
|
|
150
|
-
// record's versions are only on the opener's machine; skip any that fail.
|
|
151
|
-
for (const m of merges) {
|
|
152
|
-
if (m.state !== 'open' || m.local) continue
|
|
153
|
-
try { session.prepareMergeSend(m.id) } catch {}
|
|
154
|
-
}
|
|
155
|
-
return { merges }
|
|
156
|
-
},
|
|
157
|
-
'POST /merges/resolve': (b) => session.settleMerge(String(b.id || ''), { how: b.how }),
|
|
152
|
+
// Merges Quilt made on its own (session.js autoSettle), and files waiting for their holder to let go.
|
|
153
|
+
'GET /merges': () => ({ merges: session.mergeList() }),
|
|
158
154
|
// quilt_clash: a file held because the session and GitHub changed the same lines (relay-clash.js).
|
|
159
155
|
'POST /clash': (b) => session.clash({ action: String(b.action || ''), path: String(b.path || ''), content: typeof b.content === 'string' ? b.content : null, checks: b.checks && typeof b.checks === 'object' ? b.checks : null, force: b.force === true }),
|
|
160
|
-
'POST /merges/send': (b) => session.sendMerge(String(b.id || '')),
|
|
161
156
|
'GET /rules': () => ({ text: session.projectRulesText() }),
|
|
157
|
+
// Secrets (`quilt run`, quilt_secret_names): asked of the API as this session's own identity
|
|
158
|
+
// (its person, or the agent it runs as). Run tokens and names only: never a value.
|
|
159
|
+
'POST /secrets/run': async (b) => createRun(await secretsApi(), { room: session.room, label: String(b.label || '').slice(0, 200) }),
|
|
160
|
+
'POST /secrets/run/wait': async (b) => waitRun(await secretsApi(), String(b.id || '')),
|
|
161
|
+
'POST /secrets/run/end': async (b) => endRun(await secretsApi(), String(b.id || '')),
|
|
162
|
+
'GET /secrets/names': async () => secretNames(await secretsApi(), session.room),
|
|
162
163
|
'GET /info': (b) => ({ room: session.room, dir: session.root, name: session.actorName(b.via), person: session.name, kind: session.kind, invite: extras.invite || null, viewInvite: extras.viewInvite || null, access: session.access, pid: process.pid })
|
|
163
164
|
}
|
|
164
165
|
const server = http.createServer(async (req, res) => {
|
|
@@ -233,11 +234,13 @@ export function findDaemon (start = process.env.QUILT_DIR || process.cwd()) {
|
|
|
233
234
|
}
|
|
234
235
|
}
|
|
235
236
|
|
|
236
|
-
|
|
237
|
+
/** `timeoutMs`: give up (throw) when the session hasn't answered by then (git's helpers: git must never wait on Quilt). */
|
|
238
|
+
export async function call (daemon, method, route, body, { timeoutMs = 0 } = {}) {
|
|
237
239
|
const res = await fetch(`http://127.0.0.1:${daemon.port}${route}`, {
|
|
238
240
|
method,
|
|
239
241
|
headers: { authorization: `Bearer ${daemon.token}`, 'content-type': 'application/json' },
|
|
240
|
-
body: body ? JSON.stringify(body) : undefined
|
|
242
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
243
|
+
signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined
|
|
241
244
|
})
|
|
242
245
|
const json = await res.json()
|
|
243
246
|
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`)
|
package/src/fsutil.js
CHANGED
|
@@ -4,12 +4,22 @@ import crypto from 'node:crypto'
|
|
|
4
4
|
import { makeIgnore, isIgnored, scopeIgnore, IGNORE_FILES } from './pathrules.js'
|
|
5
5
|
|
|
6
6
|
export { IGNORE_FILES } from './pathrules.js'
|
|
7
|
-
export { MAX_TEXT_BYTES, MAX_BINARY_BYTES, LARGE_FILE_BYTES, MAX_STORED_BINARY_BYTES, isIgnored, isSafeRelPath, globMatcher, patternsOverlap } from './pathrules.js'
|
|
7
|
+
export { MAX_TEXT_BYTES, MAX_BINARY_BYTES, LARGE_FILE_BYTES, MAX_STORED_BINARY_BYTES, CHAT_FILES, isIgnored, isSafeRelPath, globMatcher, patternsOverlap } from './pathrules.js'
|
|
8
8
|
|
|
9
9
|
export function toPosix (p) {
|
|
10
10
|
return p.split(path.sep).join('/')
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Closes a chokidar watcher for good. chokidar calls its own add() when the last file it watches
|
|
15
|
+
* goes (to watch for its return), and add() reopens a closed watcher: a scan still under way at
|
|
16
|
+
* close (an index.lock that came and went) left a watch open that kept the process alive.
|
|
17
|
+
*/
|
|
18
|
+
export function closeWatcher (w) {
|
|
19
|
+
w.add = () => w
|
|
20
|
+
return w.close()
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
/**
|
|
14
24
|
* Builds the ignore matcher from built-ins and every .gitignore / .quiltignore
|
|
15
25
|
* in the project, each applying to its own folder. Folders already ignored
|
package/src/gitstate.js
CHANGED
|
@@ -11,7 +11,7 @@ import os from 'node:os'
|
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import { execFile } from 'node:child_process'
|
|
13
13
|
import { watch } from 'chokidar'
|
|
14
|
-
import { looksBinary, sha1, MAX_STORED_BINARY_BYTES } from './fsutil.js'
|
|
14
|
+
import { looksBinary, sha1, MAX_STORED_BINARY_BYTES, closeWatcher } from './fsutil.js'
|
|
15
15
|
|
|
16
16
|
export const GIT_TIMEOUT_MS = 5000
|
|
17
17
|
export const SETTLE_MS = 2000
|
|
@@ -343,6 +343,23 @@ export async function changesBetween (root, shaA, shaB) {
|
|
|
343
343
|
return changes
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
/**
|
|
347
|
+
* When each path last changed in `from`..`to`, by commit time (ms): Map path -> ms, in one git log.
|
|
348
|
+
* What "newer" means for the lines both a catch-up's commits and the session changed (upstream.js combine).
|
|
349
|
+
*/
|
|
350
|
+
export async function commitTimes (root, from, to) {
|
|
351
|
+
const out = await run(root, ['log', '--format=%x1e%ct', '--name-only', '--no-renames', `${from}..${to}`])
|
|
352
|
+
const times = new Map()
|
|
353
|
+
if (out === null) return times
|
|
354
|
+
for (const chunk of out.split('\x1e')) {
|
|
355
|
+
const [ct, ...names] = chunk.split('\n')
|
|
356
|
+
const ms = Number(ct) * 1000
|
|
357
|
+
if (!Number.isFinite(ms) || !ms) continue
|
|
358
|
+
for (const rel of names) if (rel && !times.has(rel)) times.set(rel, ms) // newest first
|
|
359
|
+
}
|
|
360
|
+
return times
|
|
361
|
+
}
|
|
362
|
+
|
|
346
363
|
/** Paths that differ between two commits. */
|
|
347
364
|
export async function changedBetween (root, shaA, shaB) {
|
|
348
365
|
const changes = await changesBetween(root, shaA, shaB)
|
|
@@ -466,7 +483,7 @@ export function watchGit (root, onEvent) {
|
|
|
466
483
|
}
|
|
467
484
|
}
|
|
468
485
|
watcher.on('add', onAny).on('change', onAny).on('unlink', onAny).on('addDir', onAny).on('unlinkDir', onAny)
|
|
469
|
-
return { close: () => Promise.all([watcher
|
|
486
|
+
return { close: () => Promise.all([closeWatcher(watcher), closeWatcher(refs)]) }
|
|
470
487
|
}
|
|
471
488
|
|
|
472
489
|
/** The repository's shared git directory (a worktree's .git/worktrees/<name> points at it), or null. */
|
|
@@ -561,6 +578,13 @@ export const shellQuote = (s) => `'${String(s).replace(/'/g, "'\\''")}'`
|
|
|
561
578
|
|
|
562
579
|
const HOOK_MARK = '# Quilt pre-push hook'
|
|
563
580
|
|
|
581
|
+
/**
|
|
582
|
+
* The Quilt CLI as git runs it (through sh: a helper starting with !, and the hook). In the desktop
|
|
583
|
+
* app `node` is the app itself, which without ELECTRON_RUN_AS_NODE opens as the app and never
|
|
584
|
+
* answers, so git waited for ever; plain node ignores it.
|
|
585
|
+
*/
|
|
586
|
+
const cliRun = (node, script) => `ELECTRON_RUN_AS_NODE=1 ${shellQuote(node)} ${shellQuote(script)}`
|
|
587
|
+
|
|
564
588
|
/**
|
|
565
589
|
* Lets an agent's folder use git like anyone: git asks Quilt for credentials for `host`
|
|
566
590
|
* (`quilt git-credential`, which asks the running session, which asks the relay for a short-lived
|
|
@@ -573,7 +597,7 @@ const HOOK_MARK = '# Quilt pre-push hook'
|
|
|
573
597
|
export async function installGitHelpers (root, { node, script, host = 'github.com', origin = '', identity = null }) {
|
|
574
598
|
const dir = gitDir(root)
|
|
575
599
|
if (!dir || !node || !script) return { helper: false, hook: false }
|
|
576
|
-
const cmd = (sub) => `!${
|
|
600
|
+
const cmd = (sub) => `!${cliRun(node, script)} ${sub} --dir ${shellQuote(root)}`
|
|
577
601
|
let helper = false
|
|
578
602
|
if (host) {
|
|
579
603
|
// `origin`: the remote's scheme, host and port (the relay's git, http:// in tests); else https://<host>.
|
|
@@ -609,7 +633,7 @@ async function writeQuiltHook (root, { node, script }) {
|
|
|
609
633
|
let now = ''
|
|
610
634
|
try { now = fs.readFileSync(file, 'utf8') } catch {}
|
|
611
635
|
if (now && !now.includes(HOOK_MARK)) return false
|
|
612
|
-
const want = `#!/bin/sh\n${HOOK_MARK}: pushes from this folder follow the session's git setting (git for everyone; for agents, quilt_agent_commits), and the session is told what was pushed.\
|
|
636
|
+
const want = `#!/bin/sh\n${HOOK_MARK}: pushes from this folder follow the session's git setting (git for everyone; for agents, quilt_agent_commits), and the session is told what was pushed.\n${cliRun(node, script).replace(/ /, ' exec ')} git-pre-push --dir ${shellQuote(root)} "$@"\n`
|
|
613
637
|
if (now !== want) {
|
|
614
638
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
615
639
|
fs.writeFileSync(file, want, { mode: 0o755 })
|
|
@@ -646,7 +670,7 @@ export async function setPersonGit (root, { node, script, url = '', helper = fal
|
|
|
646
670
|
for (const k of await quiltHelperKeys(root)) if (k !== key) await call(root, ['config', '--local', '--unset-all', k])
|
|
647
671
|
let helperOn = false
|
|
648
672
|
if (key) {
|
|
649
|
-
const want = `!${
|
|
673
|
+
const want = `!${cliRun(node, script)} git-credential --dir ${shellQuote(root)} --fallback`
|
|
650
674
|
const have = String((await call(root, ['config', '--local', '--get-all', key])).out || '').split('\n')
|
|
651
675
|
if (have.length !== 2 || have[0] !== '' || have[1] !== want) {
|
|
652
676
|
await call(root, ['config', '--local', '--replace-all', key, ''])
|
package/src/hooks.js
CHANGED
|
@@ -187,7 +187,7 @@ function mergesContext (merges) {
|
|
|
187
187
|
}
|
|
188
188
|
const shown = open.slice(0, 5).map(describe).join(', ')
|
|
189
189
|
const more = open.length > 5 ? `, and ${open.length - 5} more` : ''
|
|
190
|
-
return `${open.length} file(s)
|
|
190
|
+
return `${open.length} file(s) wait for whoever holds them: ${shown}${more}. Quilt merges those changes in by itself when they let go; leave those files alone until then.`
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
async function preEdit (event, d, api, state, say, name) {
|