import { z } from "zod"; import { badRequest, conflict, HttpError, notFoundError } from "../../errors"; import { forkRepo } from "../../api/repos"; import { announcePush, assertDirectUpdate, rethrowPush } from "../../git/web-commit"; import { branchSha, listRefs } from "../../git/refs"; import { signatureNow } from "../../git/objects"; import { withStore, ZERO_SHA } from "../../git/store"; import { commitChanges, createBranch, isValidRefName } from "../../git/writer"; import type { FileChangeInput } from "../../git/writer"; import { limitWrite } from "../../limits"; import type { McpCaller } from "../protocol"; import { repoHub } from "../hub-do"; import { normalizeClaimPath, overlaps } from "../coordination"; import type { Lease } from "../coordination"; import { apiCtx, apiJson, defineTool, repoArgs, scopedRepo, withTrailers } from "./base"; import { resolveRef } from "./content"; import { holderKey } from "./coordination"; const MAX_FILES = 100; const MAX_TOTAL_BYTES = 8 * 1024 * 1024; const MAX_SLUG = 40; const WORKSPACE_CLAIM_MS = 2 * 60 * 60 * 1000; const branchName = z.string().min(1).max(200); function slugify(value: string): string { return ( value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, MAX_SLUG) || "task" ); } async function createBranchAt(caller: McpCaller, ownerName: string, repoName: string, name: string, from?: string) { if (!isValidRefName(name)) throw badRequest("Invalid branch name"); await limitWrite(caller.viewer); const { repo, permission } = await scopedRepo(caller, ownerName, repoName, "contents", "write"); const refs = await listRefs(repo); if (branchSha(refs, name)) throw conflict("That branch already exists"); const base = await resolveRef(repo, from); await assertDirectUpdate(repo, permission, name, false); await createBranch(repo, name, base.sha).catch(rethrowPush); await announcePush(repo, caller.viewer, `refs/heads/${name}`, ZERO_SHA, base.sha); return { repo, branch: name, sha: base.sha, from: base.name }; } const createBranchTool = defineTool({ name: "create_branch", title: "Create branch", description: "Create a branch from a ref (default branch if omitted). Branch protection applies.", input: z.object({ ...repoArgs, branch: branchName, from: z.string().max(250).optional() }), write: true, async run({ caller }, args) { const created = await createBranchAt(caller, args.owner, args.repo, args.branch, args.from); return { structured: { branch: created.branch, sha: created.sha, from: created.from }, repoId: created.repo.id }; }, }); const createWorkspaceTool = defineTool({ name: "create_workspace", title: "Create agent workspace", description: "Create an isolated workspace for one task: a branch agent// (mode=branch) or a fork into your account (mode=fork, needs an account-level grant). Also claims the task so other agents see it, so it needs contents:write on the source repository in both modes.", input: z.object({ ...repoArgs, task: z.string().trim().min(1).max(200), mode: z.enum(["branch", "fork"]).default("branch"), from: z.string().max(250).optional(), }), write: true, async run({ caller }, args) { const slug = slugify(args.task); const { repo } = await scopedRepo(caller, args.owner, args.repo, "contents", "write"); const hub = repoHub(repo.id); const holder = holderKey(caller); const claim = await hub.claim({ kind: "task", keys: [slug], holder, agent: caller.agent.name, user: caller.agent.authorizedBy, note: args.task, ttlMs: WORKSPACE_CLAIM_MS, force: false, }); if (claim.conflicts.length > 0) { const holderLease = claim.conflicts[0].lease; throw conflict(`Task "${slug}" is already claimed by ${holderLease.agent} (@${holderLease.user})`); } const releaseOnFailure = async (error: unknown): Promise => { await hub.release( holder, claim.granted.map((lease) => lease.id).filter((id) => !claim.renewed.includes(id)), ); throw error; }; if (args.mode === "fork") { const name = `${repo.name}-${slug}`.slice(0, 100); const fork = await apiJson<{ owner: string; name: string; default_branch: string }>( forkRepo(apiCtx(caller, { body: { name } }), repo.owner_name, repo.name), ).catch(releaseOnFailure); return { structured: { mode: "fork", owner: fork.owner, repo: fork.name, branch: fork.default_branch, task: slug }, repoId: repo.id, }; } const agentSlug = slugify(caller.agent.name); const created = await createBranchAt(caller, args.owner, args.repo, `agent/${agentSlug}/${slug}`, args.from).catch( releaseOnFailure, ); return { structured: { mode: "branch", owner: repo.owner_name, repo: repo.name, branch: created.branch, sha: created.sha, task: slug, }, repoId: repo.id, }; }, }); const fileChange = z.object({ path: z.string().min(1).max(4096), content: z.string().optional().describe("New file content; omit with delete=true to remove the file"), encoding: z.enum(["utf8", "base64"]).default("utf8"), delete: z.boolean().default(false), executable: z.boolean().default(false), }); function decode(change: z.infer): Uint8Array | null { if (change.delete) return null; if (change.content === undefined) throw badRequest(`content is required for ${change.path}`); if (change.encoding === "utf8") return new TextEncoder().encode(change.content); try { return Uint8Array.from(atob(change.content.replace(/\s+/g, "")), (char) => char.charCodeAt(0)); } catch { throw badRequest(`content for ${change.path} is not valid base64`); } } function othersClaims(leases: Lease[], holder: string, paths: string[]) { return paths.flatMap((path) => leases .filter((lease) => lease.holder !== holder && overlaps("file", lease.key, path)) .map((lease) => ({ path, claimed: lease.key, agent: lease.agent, user: lease.user, expires_at: lease.expiresAt, })), ); } const commitOutput = z.object({ sha: z.string(), branch: z.string(), parent: z.string().nullable(), files: z.number(), claim_warnings: z.array( z.object({ path: z.string(), claimed: z.string(), agent: z.string(), user: z.string(), expires_at: z.number() }), ), }); const commitFilesTool = defineTool({ name: "commit_files", title: "Commit files", description: "Commit a batch of file creations, edits and deletions in one commit. Pass expected_head to fail instead of clobbering concurrent pushes. Files claimed by other agents are reported in claim_warnings (or refused with respect_claims).", input: z.object({ ...repoArgs, branch: branchName, message: z.string().trim().min(1).max(10_000), files: z.array(fileChange).min(1).max(MAX_FILES), expected_head: z.string().length(40).optional(), create_from: z.string().max(250).optional().describe("Create the branch from this ref if it does not exist"), respect_claims: z.boolean().default(false), }), output: commitOutput, write: true, async run({ caller }, args) { await limitWrite(caller.viewer); const { repo, permission } = await scopedRepo(caller, args.owner, args.repo, "contents", "write"); const changes: FileChangeInput[] = []; let total = 0; for (const change of args.files) { const content = decode(change); total += content?.byteLength ?? 0; changes.push({ path: change.path, content, mode: change.executable ? "100755" : undefined }); } if (total > MAX_TOTAL_BYTES) throw badRequest("Commit is too large; split it into several commits"); const refs = await listRefs(repo); let parent = branchSha(refs, args.branch); let created = false; if (!parent && args.create_from) { parent = (await resolveRef(repo, args.create_from)).sha; created = true; } else if (!parent && refs.branches.length > 0) throw notFoundError("Branch not found; pass create_from"); if (args.expected_head && parent !== args.expected_head) { throw conflict(`Branch moved: expected ${args.expected_head}, found ${parent ?? "none"}`); } const holder = holderKey(caller); const warnings = othersClaims( await repoHub(repo.id).leases("file"), holder, args.files.map((file) => normalizeClaimPath(file.path) ?? file.path), ); if (warnings.length > 0 && args.respect_claims) { throw new HttpError( 409, `Files are claimed by other agents: ${warnings.map((w) => `${w.path} (${w.agent})`).join(", ")}`, ); } await assertDirectUpdate(repo, permission, args.branch, false); const sha = await withStore(repo.artifacts_name, (store) => commitChanges({ repo, store, branch: args.branch, parentSha: parent, changes, message: withTrailers(args.message, caller), author: signatureNow(caller.viewer.displayName ?? caller.viewer.name, caller.viewer.email), }), ).catch(rethrowPush); const before = created || !parent ? ZERO_SHA : parent; await announcePush(repo, caller.viewer, `refs/heads/${args.branch}`, before, sha); return { structured: commitOutput.parse({ sha, branch: args.branch, parent, files: changes.length, claim_warnings: warnings, }), repoId: repo.id, }; }, }); export const gitTools = [createBranchTool, createWorkspaceTool, commitFilesTool];