UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

543 lines (542 loc) 21.1 kB
import { s as asFiniteNumber } from "./number-coercion-CLj0HTDM.js"; import { c as isRecord } from "./record-coerce-DItp3I4t.js"; import { l as normalizeOptionalString, m as readNonBlankString } from "./string-coerce-CIXf7egm.js"; import { n as normalizeAgentId } from "./agent-id-CeT3w4ap.js"; import { m as resolveAgentWorkspaceDir, y as resolveDefaultAgentId } from "./agent-scope-config-DcbEhP0R.js"; import { t as pruneMapToMaxSize } from "./map-size-CNcWiFKu.js"; import { O as parseAgentSessionKey } from "./session-key-BnWWjqNc.js"; import "./agent-scope-DbtJyKUL.js"; import { u as runGit } from "./git-BuLvhVpD.js"; import { i as loadGatewaySessionEntryReadOnly } from "./session-utils-store-CInT2loy.js"; import "./session-utils-Cai0_C6U.js"; import { m as resolveGitHubApiCredentialScope, n as ControlUiGitHubError, r as GITHUB_API_ORIGIN, s as fetchGitHubJson } from "./control-ui-github-api-CCpVJnCA.js"; import { r as parseGitHubRemoteUrl, t as resolveGitHubForkParent } from "./github-repository-target-OmNGFEYv.js"; import path from "node:path"; import fs from "node:fs/promises"; //#region src/gateway/control-ui-session-prs-landing.ts async function gitOutput(cwd, args) { try { const result = await runGit(cwd, args); return result.code === 0 ? result.stdout.trim() || null : null; } catch { return null; } } async function isAncestor(root, ancestor, descendant) { try { return (await runGit(root, [ "merge-base", "--is-ancestor", ancestor, descendant ])).code === 0; } catch { return false; } } /** * Picks the newest of the known-published baseline candidates. Two candidates * (the dominant case) stay a single ancestry call; larger sets batch through * one `merge-base --independent`. Among incomparable maxima, a candidate * containing the first entry (the merge base) is preferred — any such pick is * sound because it covers everything the merge base covers — and otherwise * the earliest candidate wins, keeping the merge base as the safe default. */ async function maximalCommit(root, candidates) { const unique = [...new Set(candidates)]; const first = unique[0]; if (first === void 0) return null; if (unique.length === 1) return first; const second = unique[1]; if (unique.length === 2 && second !== void 0) return await isAncestor(root, first, second) ? second : first; const out = await gitOutput(root, [ "merge-base", "--independent", ...unique ]); if (!out) return first; const independent = new Set(out.split("\n").map((line) => line.trim()).filter(Boolean)); if (independent.has(first)) return first; for (const candidate of unique.slice(1)) if (independent.has(candidate) && await isAncestor(root, first, candidate)) return candidate; return unique.find((candidate) => independent.has(candidate)) ?? first; } async function resolveBranchLanding(root, params) { const pushedSha = await gitOutput(root, [ "rev-parse", "--verify", "--quiet", `refs/remotes/origin/${params.branch}` ]); const headSha = await gitOutput(root, ["rev-parse", "HEAD"]); const defaultRef = params.defaultBranch ? `refs/remotes/origin/${params.defaultBranch}` : null; const landedHeads = []; for (const head of params.mergedHeads) if (head.baseRef === params.defaultBranch) landedHeads.push(head); else if (defaultRef && head.mergeCommitSha && await isAncestor(root, head.mergeCommitSha, defaultRef)) landedHeads.push(head); const landedShas = landedHeads.map((head) => head.sha); const mergeBase = defaultRef ? await gitOutput(root, [ "merge-base", defaultRef, "HEAD" ]) : null; const baselines = mergeBase ? [mergeBase] : []; if (headSha) { for (const merged of landedShas) if (await isAncestor(root, merged, headSha)) baselines.push(merged); else if (await isAncestor(root, headSha, merged)) baselines.push(headSha); } const statsBase = await maximalCommit(root, baselines); let provenNewPushedWork = false; if (pushedSha && mergeBase && !landedShas.includes(pushedSha.toLowerCase())) { provenNewPushedWork = landedHeads.length > 0; for (const head of landedHeads) if (!((head.mergeCommitSha ? await isAncestor(root, head.mergeCommitSha, mergeBase) : false) || await isAncestor(root, head.sha, mergeBase))) { provenNewPushedWork = false; break; } } return { pushedSha, statsBase, hasLandedPullRequest: landedHeads.length > 0, provenNewPushedWork }; } //#endregion //#region src/gateway/control-ui-session-prs-local-git.ts const LOCAL_GIT_CACHE_MS = 75e3; const LOCAL_GIT_CACHE_LIMIT = 100; function createLocalGitCache() { const entries = /* @__PURE__ */ new Map(); return (key, refresh, load) => { const cached = entries.get(key); if (!refresh && cached && cached.expiresAt > Date.now()) { entries.delete(key); entries.set(key, cached); return cached.promise; } const entry = { expiresAt: Date.now() + LOCAL_GIT_CACHE_MS, promise: load() }; entries.delete(key); entries.set(key, entry); pruneMapToMaxSize(entries, LOCAL_GIT_CACHE_LIMIT); return entry.promise; }; } const cachedGitContext = createLocalGitCache(); const cachedBranchFacts = createLocalGitCache(); function resolveCachedGitContext(root, deps, refresh = false) { return cachedGitContext(root, refresh, async () => { const output = deps.gitOutput ?? gitOutput; const branch = await output(root, [ "rev-parse", "--abbrev-ref", "HEAD" ]); if (!branch || branch === "HEAD") return null; const remoteUrl = await output(root, [ "remote", "get-url", "origin" ]); const remote = remoteUrl ? parseGitHubRemoteUrl(remoteUrl) : null; if (!remote) return null; const defaultBranch = (await output(root, [ "symbolic-ref", "--short", "refs/remotes/origin/HEAD" ]))?.replace(/^origin\//, ""); if (defaultBranch === branch) return null; return { ...remote, branch, root, ...defaultBranch ? { defaultBranch } : {} }; }); } function resolveCachedSessionBranchFacts(context, mergedHeads, load, refresh = false) { const landingKey = JSON.stringify([context.defaultBranch ?? null, mergedHeads]); return cachedBranchFacts(`${context.root}\0${context.branch}\0${landingKey}`, refresh, load); } //#endregion //#region src/gateway/control-ui-session-prs.ts const SUCCESS_CACHE_MS = 9e4; const RATE_LIMIT_CACHE_MS = 3e5; const FAILURE_CACHE_MS = 3e4; const CACHE_LIMIT = 100; const MAX_PULL_REQUESTS = 3; const branchCache = /* @__PURE__ */ new Map(); /** Resolves the checkout root without spawning Git. */ function resolveSessionPullRequestGitRoot(params) { const { cfg, entry, storePath, canonicalKey } = loadGatewaySessionEntryReadOnly(params.sessionKey, { agentId: params.agentId }); if (!entry?.sessionId || !storePath) return null; const agentId = normalizeAgentId(parseAgentSessionKey(canonicalKey)?.agentId ?? params.agentId ?? parseAgentSessionKey(params.sessionKey)?.agentId ?? resolveDefaultAgentId(cfg)); const root = normalizeOptionalString(entry.spawnedCwd) ?? normalizeOptionalString(entry.spawnedWorkspaceDir) ?? normalizeOptionalString(resolveAgentWorkspaceDir(cfg, agentId)); if (!root) return null; return root; } /** * Resolves the GitHub repo + branch, caching detached/default/non-GitHub * outcomes too so repeated sidebar requests do not respawn the same probes. */ async function resolveSessionPullRequestGitContext(params, deps) { const root = deps.resolveGitRoot ? await deps.resolveGitRoot(params) : resolveSessionPullRequestGitRoot(params); if (!root) return null; return resolveCachedGitContext(root, deps, params.refresh === true); } function branchCreateUrl(context) { return `https://github.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repo)}/pull/new/${context.branch.split("/").map(encodeURIComponent).join("/")}`; } const SHORTSTAT_FILES = /(\d+) files? changed/; const SHORTSTAT_INSERTIONS = /(\d+) insertion/; const SHORTSTAT_DELETIONS = /(\d+) deletion/; const MAX_UNTRACKED_STAT_FILES = 100; const MAX_UNTRACKED_STAT_BYTES = 524288; /** * Line count for one untracked file, computed in-process: this runs on the * chat view's poll, so it must not spawn one git subprocess per path. lstat * gates on regular files so FIFOs/sockets can never block the RPC and symlinks * never resolve outside the checkout; only a line count is exposed, so * sessions-diff's hardlink content guard is unnecessary here. */ async function untrackedFileAdditions(root, filePath) { try { const abs = path.resolve(root, filePath); const info = await fs.lstat(abs); if (!info.isFile() || info.size === 0 || info.size > MAX_UNTRACKED_STAT_BYTES) return 0; const body = await fs.readFile(abs); if (body.subarray(0, 8192).includes(0)) return 0; let lines = 0; for (const byte of body) if (byte === 10) lines += 1; return body[body.length - 1] === 10 ? lines : lines + 1; } catch { return 0; } } async function untrackedStats(root, output) { const paths = (await output(root, [ "ls-files", "--others", "--exclude-standard", "-z" ]) ?? "").split("\0").filter(Boolean); let additions = 0; for (const filePath of paths.slice(0, MAX_UNTRACKED_STAT_FILES)) additions += await untrackedFileAdditions(root, filePath); return { additions, files: paths.length }; } /** * Working-tree diff counts vs an explicit base, untracked files included: * the size the PR would have if the current work were committed and pushed; * changedFiles decides row visibility for unpushed branches. Unlike bare * `git diff`, this also counts unmerged (conflict) paths. */ async function diffStatsAgainst(root, base, deps) { try { const result = await (deps.runGit ?? runGit)(root, [ "diff", "--shortstat", "--no-ext-diff", "--no-textconv", base ]); if (result.code !== 0) return null; const summary = result.stdout.trim(); const untracked = await untrackedStats(root, deps.gitOutput ?? gitOutput); return { additions: Number(SHORTSTAT_INSERTIONS.exec(summary)?.[1] ?? 0) + untracked.additions, deletions: Number(SHORTSTAT_DELETIONS.exec(summary)?.[1] ?? 0), changedFiles: Number(SHORTSTAT_FILES.exec(summary)?.[1] ?? 0) + untracked.files }; } catch { return null; } } /** * GitHub's pull/new page only has something to offer once the pushed branch * carries commits the default branch lacks. Rename-only commits still count: * this gate keys on commits, not line counts. */ async function branchHasCreatablePullRequest(root, context, pushedSha, output) { if (!context.defaultBranch || !pushedSha) return false; const ahead = await output(root, [ "rev-list", "--count", `refs/remotes/origin/${context.defaultBranch}..refs/remotes/origin/${context.branch}` ]); return ahead === null || Number(ahead) > 0; } async function resolveSessionBranch(context, mergedHeads, deps, refresh) { const root = context.root; if (!root) return { owner: context.owner, repo: context.repo, branch: context.branch, createUrl: branchCreateUrl(context) }; const facts = await resolveCachedSessionBranchFacts({ ...context, root }, mergedHeads, async () => { const landing = await (deps.resolveBranchLanding ?? resolveBranchLanding)(root, { branch: context.branch, defaultBranch: context.defaultBranch, mergedHeads }); const creatable = (!landing.hasLandedPullRequest || landing.provenNewPushedWork) && await branchHasCreatablePullRequest(root, context, landing.pushedSha, deps.gitOutput ?? gitOutput); const stats = landing.statsBase ? await diffStatsAgainst(root, landing.statsBase, deps) : null; return !creatable && !(stats && stats.changedFiles > 0) ? void 0 : { creatable, stats }; }, refresh); if (!facts) return; return { owner: context.owner, repo: context.repo, branch: context.branch, ...facts.creatable ? { createUrl: branchCreateUrl(context) } : {}, ...facts.stats ? { additions: facts.stats.additions, deletions: facts.stats.deletions, changedFiles: facts.stats.changedFiles } : {} }; } function derivePullState(value) { if (readNonBlankString(value.merged_at)) return "merged"; if (value.state !== "open") return "closed"; return value.draft === true ? "draft" : "open"; } function parsePullListItem(value) { if (!isRecord(value)) return null; const number = asFiniteNumber(value.number); const title = readNonBlankString(value.title); const url = readNonBlankString(value.html_url); const base = isRecord(value.base) ? value.base : {}; const baseRepo = isRecord(base.repo) ? base.repo : {}; const baseOwner = isRecord(baseRepo.owner) ? baseRepo.owner : {}; const owner = readNonBlankString(baseOwner.login); const repo = readNonBlankString(baseRepo.name); const head = isRecord(value.head) ? value.head : {}; if (!number || !Number.isSafeInteger(number) || number < 1 || !title || !url || !owner || !repo) return null; const user = isRecord(value.user) ? value.user : {}; const authorLogin = readNonBlankString(user.login); return { number, title, url, owner, repo, state: derivePullState(value), ...authorLogin ? { author: { login: authorLogin } } : {}, headSha: readNonBlankString(head.sha), baseRef: readNonBlankString(base.ref), mergeCommitSha: readNonBlankString(value.merge_commit_sha) }; } function parsePullList(value) { if (!Array.isArray(value)) return []; return value.map(parsePullListItem).filter((item) => item !== null); } function pullsByHeadUrl(owner, repo, head) { return `${GITHUB_API_ORIGIN}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?head=${encodeURIComponent(head)}&state=all&sort=updated&direction=desc&per_page=5`; } async function fetchParentRepo(owner, repo, fetchImpl, token) { const url = `${GITHUB_API_ORIGIN}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const value = await fetchGitHubJson(url, fetchImpl, token); return resolveGitHubForkParent(value) ?? null; } function rethrowRateLimit(error) { if (error instanceof ControlUiGitHubError && error.statusCode === 429) throw error; } const FAILING_CHECK_CONCLUSIONS = /* @__PURE__ */ new Set([ "failure", "timed_out", "cancelled", "action_required", "startup_failure" ]); const CHECK_PAGE_SIZE = 100; const MAX_CHECK_PAGES = 10; const CHECK_PAGE_BYTES = 1048576; async function fetchChecks(item, fetchImpl, token) { if (!item.headSha || !/^[0-9a-f]{40}$/i.test(item.headSha)) return; const url = `${GITHUB_API_ORIGIN}/repos/${encodeURIComponent(item.owner)}/${encodeURIComponent(item.repo)}/commits/${item.headSha}/check-runs?per_page=${CHECK_PAGE_SIZE}`; const counts = { passed: 0, failed: 0, skipped: 0, running: 0 }; for (let page = 1; page <= MAX_CHECK_PAGES; page += 1) { const value = await fetchGitHubJson(`${url}&page=${page}`, fetchImpl, token, CHECK_PAGE_BYTES); if (!isRecord(value) || !Array.isArray(value.check_runs) || value.check_runs.length > CHECK_PAGE_SIZE) return; for (const runValue of value.check_runs) { const run = isRecord(runValue) ? runValue : {}; const conclusion = readNonBlankString(run.conclusion); if (conclusion && FAILING_CHECK_CONCLUSIONS.has(conclusion)) counts.failed += 1; else if (run.status !== "completed" || conclusion === "stale") counts.running += 1; else counts[conclusion === "skipped" ? "skipped" : "passed"] += 1; } const seen = counts.passed + counts.failed + counts.skipped + counts.running; if (seen > 0 && seen === value.total_count) return { state: counts.failed > 0 ? "failing" : counts.running > 0 ? "pending" : "passing", ...counts }; if (value.check_runs.length < CHECK_PAGE_SIZE) return; } } /** * The facts a chip carries without spending quota on per-PR detail calls. The * rate-limited path renders exactly this, so both callers share one shape. */ function stateOnlyPullRequestChip(item, branch) { return { number: item.number, owner: item.owner, repo: item.repo, branch, title: item.title, url: item.url, state: item.state, ...item.author ? { author: item.author } : {} }; } async function finishPullRequest(item, branch, fetchImpl, token) { const chip = stateOnlyPullRequestChip(item, branch); if (item.state !== "open" && item.state !== "draft") return chip; const detailUrl = `${GITHUB_API_ORIGIN}/repos/${encodeURIComponent(item.owner)}/${encodeURIComponent(item.repo)}/pulls/${item.number}`; const [details, checks] = await Promise.all([fetchGitHubJson(detailUrl, fetchImpl, token).catch(rethrowRateLimit), fetchChecks(item, fetchImpl, token).catch(rethrowRateLimit)]); return { ...chip, ...isRecord(details) ? { additions: asFiniteNumber(details.additions), deletions: asFiniteNumber(details.deletions), changedFiles: asFiniteNumber(details.changed_files) } : {}, ...checks ? { checks, checksUrl: `${item.url}/checks` } : {} }; } function mergedHeadsOf(items) { const heads = []; for (const item of items) if (item.state === "merged" && item.headSha) heads.push({ sha: item.headSha.toLowerCase(), ...item.baseRef ? { baseRef: item.baseRef } : {}, ...item.mergeCommitSha ? { mergeCommitSha: item.mergeCommitSha.toLowerCase() } : {} }); return heads; } async function fetchBranchPullRequests(context, fetchImpl, token) { const head = `${context.owner}:${context.branch}`; let items = parsePullList(await fetchGitHubJson(pullsByHeadUrl(context.owner, context.repo, head), fetchImpl, token)); if (items.length === 0) { const parent = await fetchParentRepo(context.owner, context.repo, fetchImpl, token); if (parent) items = parsePullList(await fetchGitHubJson(pullsByHeadUrl(parent.owner, parent.repo, head), fetchImpl, token)); } const capped = items.slice(0, MAX_PULL_REQUESTS); const mergedHeads = mergedHeadsOf(items); try { return { pullRequests: await Promise.all(capped.map((item) => finishPullRequest(item, context.branch, fetchImpl, token))), rateLimited: false, mergedHeads }; } catch (error) { if (!(error instanceof ControlUiGitHubError && error.statusCode === 429)) throw error; return { pullRequests: capped.map((item) => stateOnlyPullRequestChip(item, context.branch)), rateLimited: true, mergedHeads }; } } async function refreshBranchPullRequests(context, fetchImpl, entry, token) { try { const result = await fetchBranchPullRequests(context, fetchImpl, token); entry.lastGood = { pullRequests: result.pullRequests, mergedHeads: result.mergedHeads }; if (result.rateLimited) entry.expiresAt = Date.now() + RATE_LIMIT_CACHE_MS; return result; } catch (error) { const rateLimited = error instanceof ControlUiGitHubError && error.statusCode === 429; entry.expiresAt = Date.now() + (rateLimited ? RATE_LIMIT_CACHE_MS : FAILURE_CACHE_MS); if (rateLimited) return { pullRequests: [], mergedHeads: [], ...entry.lastGood, rateLimited: true }; if (entry.lastGood) return { ...entry.lastGood, rateLimited: false }; throw error; } } async function loadControlUiSessionPullRequests(params, deps = {}) { const context = deps.resolveGitContext ? await deps.resolveGitContext(params) : await resolveSessionPullRequestGitContext(params, deps); if (!context) return { pullRequests: [], rateLimited: false }; const { mergedHeads, ...snapshot } = await cachedBranchPullRequests(context, deps, params.refresh === true); const branch = await resolveSessionBranch(context, mergedHeads, deps, params.refresh === true); return branch ? { ...snapshot, branch } : snapshot; } function trackBranchRefresh(entry, mode, load) { entry.expiresAt = Date.now() + SUCCESS_CACHE_MS; entry.refreshMode = mode; const trackedPromise = load().finally(() => { if (entry.promise === trackedPromise) entry.refreshMode = null; }); entry.promise = trackedPromise; return trackedPromise; } async function cachedBranchPullRequests(context, deps, refresh) { const { token, cacheScope } = resolveGitHubApiCredentialScope(); const key = `${context.owner.toLowerCase()}/${context.repo.toLowerCase()}#${context.branch}\0${cacheScope}`; const cached = branchCache.get(key); if (cached && cached.expiresAt > Date.now()) { branchCache.delete(key); branchCache.set(key, cached); if (!refresh || cached.refreshMode === "forced") return cached.promise; const pendingSnapshot = cached.promise; const pendingRefreshMode = cached.refreshMode; const pendingExpiresAt = cached.expiresAt; return trackBranchRefresh(cached, "forced", async () => { const snapshot = await pendingSnapshot; if (snapshot.rateLimited) { if (pendingRefreshMode === null) cached.expiresAt = pendingExpiresAt; return snapshot; } return refreshBranchPullRequests(context, deps.fetchImpl ?? fetch, cached, token); }); } const entry = cached ?? { expiresAt: 0, promise: Promise.resolve({ pullRequests: [], rateLimited: false, mergedHeads: [] }), refreshMode: null }; const promise = trackBranchRefresh(entry, refresh ? "forced" : "normal", () => refreshBranchPullRequests(context, deps.fetchImpl ?? fetch, entry, token)); branchCache.delete(key); branchCache.set(key, entry); pruneMapToMaxSize(branchCache, CACHE_LIMIT); return promise; } //#endregion export { loadControlUiSessionPullRequests };