@posthog/agent
Version:
TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog
885 lines (827 loc) • 17.8 kB
text/typescript
import { exec, execFile } from "node:child_process";
import * as crypto from "node:crypto";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { promisify } from "node:util";
import type { WorktreeInfo } from "./types.js";
import { Logger } from "./utils/logger.js";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
export interface WorktreeConfig {
mainRepoPath: string;
worktreeBasePath?: string;
logger?: Logger;
}
const ADJECTIVES = [
"swift",
"bright",
"calm",
"bold",
"gentle",
"quick",
"soft",
"warm",
"cool",
"wise",
"keen",
"brave",
"clear",
"crisp",
"deep",
"fair",
"fine",
"free",
"glad",
"good",
"grand",
"great",
"happy",
"kind",
"light",
"lively",
"neat",
"nice",
"plain",
"proud",
"pure",
"rare",
"rich",
"safe",
"sharp",
"shy",
"simple",
"slim",
"smart",
"smooth",
"solid",
"sound",
"spare",
"stable",
"steady",
"still",
"strong",
"sure",
"sweet",
"tall",
"agile",
"ancient",
"autumn",
"azure",
"cosmic",
"daring",
"dawn",
"dusty",
"eager",
"early",
"endless",
"fading",
"fallen",
"famous",
"feral",
"fierce",
"fleet",
"foggy",
"forest",
"frozen",
"gleeful",
"golden",
"hazy",
"hidden",
"hollow",
"humble",
"hushed",
"icy",
"inner",
"late",
"lazy",
"little",
"lone",
"long",
"lost",
"lucky",
"lunar",
"magic",
"mellow",
"mighty",
"misty",
"modest",
"mossy",
"mystic",
"nimble",
"noble",
"ocean",
"outer",
"pale",
"paper",
"patient",
"peaceful",
"phantom",
"polite",
"primal",
"quiet",
"rapid",
"restless",
"rising",
"roaming",
"rocky",
"rustic",
"sacred",
"sandy",
"secret",
"serene",
"shadow",
"shining",
"silent",
"silky",
"silver",
"sleek",
"snowy",
"solar",
"solemn",
"spring",
"starry",
"stormy",
"summer",
"sunny",
"tender",
"thorny",
"tiny",
"tranquil",
"twilight",
"upward",
"velvet",
"vivid",
"wandering",
"wary",
"wild",
"windy",
"winter",
"wispy",
"young",
];
const COLORS = [
"blue",
"red",
"green",
"amber",
"coral",
"jade",
"pearl",
"ruby",
"sage",
"teal",
"gold",
"silver",
"bronze",
"copper",
"ivory",
"onyx",
"opal",
"rose",
"slate",
"violet",
"aqua",
"azure",
"beige",
"black",
"brass",
"brick",
"brown",
"cedar",
"charcoal",
"cherry",
"chestnut",
"chrome",
"cider",
"cinnamon",
"citrus",
"clay",
"cloud",
"cobalt",
"cocoa",
"cream",
"crimson",
"crystal",
"cyan",
"denim",
"dusk",
"ebony",
"ember",
"emerald",
"fern",
"flame",
"flint",
"forest",
"frost",
"garnet",
"ginger",
"glacier",
"granite",
"grape",
"gray",
"hazel",
"honey",
"indigo",
"iron",
"lapis",
"lava",
"lavender",
"lemon",
"lilac",
"lime",
"magenta",
"mahogany",
"maple",
"marble",
"maroon",
"mauve",
"midnight",
"mint",
"mocha",
"moss",
"mustard",
"navy",
"nickel",
"obsidian",
"ochre",
"olive",
"orange",
"orchid",
"peach",
"pine",
"pink",
"plum",
"porcelain",
"purple",
"quartz",
"rust",
"saffron",
"salmon",
"sand",
"sapphire",
"scarlet",
"sepia",
"shadow",
"sienna",
"smoke",
"snow",
"steel",
"stone",
"storm",
"sunset",
"tan",
"tangerine",
"taupe",
"terra",
"timber",
"topaz",
"turquoise",
"umber",
"vanilla",
"walnut",
"wheat",
"white",
"wine",
"yellow",
];
const ANIMALS = [
"fox",
"owl",
"bear",
"wolf",
"hawk",
"deer",
"lynx",
"otter",
"raven",
"falcon",
"badger",
"beaver",
"bison",
"bobcat",
"crane",
"eagle",
"ferret",
"finch",
"gopher",
"heron",
"jaguar",
"koala",
"lemur",
"marten",
"mink",
"moose",
"newt",
"ocelot",
"osprey",
"panda",
"parrot",
"pelican",
"puma",
"quail",
"rabbit",
"raccoon",
"salmon",
"seal",
"shark",
"shrew",
"sloth",
"snake",
"spider",
"squid",
"stork",
"swan",
"tiger",
"toucan",
"turtle",
"whale",
"albatross",
"ant",
"antelope",
"armadillo",
"baboon",
"bat",
"bee",
"beetle",
"buffalo",
"butterfly",
"camel",
"cardinal",
"caribou",
"catfish",
"cheetah",
"chipmunk",
"cicada",
"clam",
"cobra",
"condor",
"corgi",
"cougar",
"coyote",
"crab",
"cricket",
"crow",
"dolphin",
"donkey",
"dove",
"dragonfly",
"duck",
"eel",
"egret",
"elephant",
"elk",
"emu",
"firefly",
"flamingo",
"frog",
"gazelle",
"gecko",
"gibbon",
"giraffe",
"goat",
"goose",
"gorilla",
"grasshopper",
"grouse",
"gull",
"hamster",
"hare",
"hedgehog",
"hippo",
"hornet",
"horse",
"hound",
"hummingbird",
"hyena",
"ibis",
"iguana",
"impala",
"jackal",
"jay",
"jellyfish",
"kangaroo",
"kestrel",
"kingfisher",
"kite",
"kiwi",
"lark",
"leopard",
"lion",
"lizard",
"llama",
"lobster",
"loon",
"macaw",
"magpie",
"mallard",
"mammoth",
"manatee",
"mantis",
"marlin",
"marmot",
"meerkat",
"mockingbird",
"mole",
"mongoose",
"monkey",
"moth",
"mouse",
"mule",
"narwhal",
"nightingale",
"octopus",
"opossum",
"orangutan",
"oriole",
"ostrich",
"oyster",
"panther",
"peacock",
"penguin",
"pheasant",
"pig",
"pigeon",
"pike",
"piranha",
"platypus",
"pony",
"porcupine",
"porpoise",
"python",
"raven",
"ray",
"reindeer",
"rhino",
"robin",
"rooster",
"salamander",
"sandpiper",
"sardine",
"scorpion",
"seagull",
"seahorse",
"skunk",
"snail",
"sparrow",
"squirrel",
"starfish",
"starling",
"stingray",
"swallow",
"tapir",
"termite",
"tern",
"toad",
"trout",
"tuna",
"viper",
"vulture",
"walrus",
"wasp",
"weasel",
"wombat",
"woodpecker",
"wren",
"yak",
"zebra",
];
const WORKTREE_FOLDER_NAME = ".array";
export class WorktreeManager {
private mainRepoPath: string;
private worktreeBasePath: string | null;
private repoName: string;
private logger: Logger;
constructor(config: WorktreeConfig) {
this.mainRepoPath = config.mainRepoPath;
this.worktreeBasePath = config.worktreeBasePath || null;
this.repoName = path.basename(config.mainRepoPath);
this.logger =
config.logger ||
new Logger({ debug: false, prefix: "[WorktreeManager]" });
}
private usesExternalPath(): boolean {
return this.worktreeBasePath !== null;
}
private async runGitCommand(command: string): Promise<string> {
try {
const { stdout } = await execAsync(`git ${command}`, {
cwd: this.mainRepoPath,
});
return stdout.trim();
} catch (error) {
throw new Error(`Git command failed: ${command}\n${error}`);
}
}
private randomElement<T>(array: T[]): T {
return array[crypto.randomInt(array.length)];
}
generateWorktreeName(): string {
const adjective = this.randomElement(ADJECTIVES);
const color = this.randomElement(COLORS);
const animal = this.randomElement(ANIMALS);
return `${adjective}-${color}-${animal}`;
}
private getWorktreeFolderPath(): string {
if (this.worktreeBasePath) {
return path.join(this.worktreeBasePath, this.repoName);
}
return path.join(this.mainRepoPath, WORKTREE_FOLDER_NAME);
}
private getWorktreePath(name: string): string {
return path.join(this.getWorktreeFolderPath(), name);
}
async worktreeExists(name: string): Promise<boolean> {
const worktreePath = this.getWorktreePath(name);
try {
await fs.access(worktreePath);
return true;
} catch {
return false;
}
}
async ensureArrayDirIgnored(): Promise<void> {
// Use .git/info/exclude instead of .gitignore to avoid modifying tracked files
const excludePath = path.join(this.mainRepoPath, ".git", "info", "exclude");
const ignorePattern = `/${WORKTREE_FOLDER_NAME}/`;
let content = "";
try {
content = await fs.readFile(excludePath, "utf-8");
} catch {
// File doesn't exist or .git/info doesn't exist
}
// Check if pattern is already present
if (
content.includes(`/${WORKTREE_FOLDER_NAME}/`) ||
content.includes(`/${WORKTREE_FOLDER_NAME}`)
) {
this.logger.debug("Exclude file already contains .array folder pattern");
return;
}
// Ensure .git/info directory exists
const infoDir = path.join(this.mainRepoPath, ".git", "info");
await fs.mkdir(infoDir, { recursive: true });
// Append the pattern
const newContent = `${content.trimEnd()}\n\n# Array worktrees\n${ignorePattern}\n`;
await fs.writeFile(excludePath, newContent);
this.logger.info("Added .array folder to .git/info/exclude");
}
private async generateUniqueWorktreeName(): Promise<string> {
let name = this.generateWorktreeName();
let attempts = 0;
const maxAttempts = 100;
while ((await this.worktreeExists(name)) && attempts < maxAttempts) {
name = this.generateWorktreeName();
attempts++;
}
if (attempts >= maxAttempts) {
// Fallback: append timestamp
name = `${this.generateWorktreeName()}-${Date.now()}`;
}
return name;
}
private async getDefaultBranch(): Promise<string> {
try {
const remoteBranch = await this.runGitCommand(
"symbolic-ref refs/remotes/origin/HEAD",
);
return remoteBranch.replace("refs/remotes/origin/", "");
} catch {
// Fallback: check if main exists, otherwise use master
try {
await this.runGitCommand("rev-parse --verify main");
return "main";
} catch {
try {
await this.runGitCommand("rev-parse --verify master");
return "master";
} catch {
throw new Error(
"Cannot determine default branch. No main or master branch found.",
);
}
}
}
}
async createWorktree(): Promise<WorktreeInfo> {
// Only modify .git/info/exclude when using in-repo storage
if (!this.usesExternalPath()) {
await this.ensureArrayDirIgnored();
}
// Ensure the worktree folder exists when using external path
if (this.usesExternalPath()) {
const folderPath = this.getWorktreeFolderPath();
await fs.mkdir(folderPath, { recursive: true });
}
// Generate unique worktree name
const worktreeName = await this.generateUniqueWorktreeName();
const worktreePath = this.getWorktreePath(worktreeName);
const branchName = `posthog/${worktreeName}`;
const baseBranch = await this.getDefaultBranch();
this.logger.info("Creating worktree", {
worktreeName,
worktreePath,
branchName,
baseBranch,
external: this.usesExternalPath(),
});
// Create the worktree with a new branch
if (this.usesExternalPath()) {
// Use absolute path for external worktrees
await this.runGitCommand(
`worktree add -b "${branchName}" "${worktreePath}" "${baseBranch}"`,
);
} else {
// Use relative path from repo root for in-repo worktrees
const relativePath = `${WORKTREE_FOLDER_NAME}/${worktreeName}`;
await this.runGitCommand(
`worktree add -b "${branchName}" "./${relativePath}" "${baseBranch}"`,
);
}
const createdAt = new Date().toISOString();
this.logger.info("Worktree created successfully", {
worktreeName,
worktreePath,
branchName,
});
return {
worktreePath,
worktreeName,
branchName,
baseBranch,
createdAt,
};
}
async deleteWorktree(worktreePath: string): Promise<void> {
this.logger.info("Deleting worktree", { worktreePath });
try {
// First, try to remove the worktree via git using execFileAsync for safety
await execFileAsync(
"git",
["worktree", "remove", worktreePath, "--force"],
{
cwd: this.mainRepoPath,
},
);
this.logger.info("Worktree deleted successfully", { worktreePath });
} catch (error) {
this.logger.warn(
"Git worktree remove failed, attempting manual cleanup",
{
worktreePath,
error,
},
);
// Manual cleanup if git command fails
try {
await fs.rm(worktreePath, { recursive: true, force: true });
// Also prune the worktree list
await this.runGitCommand("worktree prune");
this.logger.info("Worktree cleaned up manually", { worktreePath });
} catch (cleanupError) {
this.logger.error("Failed to cleanup worktree", {
worktreePath,
cleanupError,
});
throw cleanupError;
}
}
}
async getWorktreeInfo(worktreePath: string): Promise<WorktreeInfo | null> {
try {
// Parse the worktree list to find info about this worktree
const output = await this.runGitCommand("worktree list --porcelain");
const worktrees = this.parseWorktreeList(output);
const worktree = worktrees.find((w) => w.worktreePath === worktreePath);
return worktree || null;
} catch (error) {
this.logger.debug("Failed to get worktree info", { worktreePath, error });
return null;
}
}
async listWorktrees(): Promise<WorktreeInfo[]> {
try {
const output = await this.runGitCommand("worktree list --porcelain");
return this.parseWorktreeList(output);
} catch (error) {
this.logger.debug("Failed to list worktrees", { error });
return [];
}
}
private parseWorktreeList(output: string): WorktreeInfo[] {
const worktrees: WorktreeInfo[] = [];
const entries = output.split("\n\n").filter((e) => e.trim());
const worktreeFolderPath = this.getWorktreeFolderPath();
for (const entry of entries) {
const lines = entry.split("\n");
let worktreePath = "";
let branchName = "";
for (const line of lines) {
if (line.startsWith("worktree ")) {
worktreePath = line.replace("worktree ", "");
} else if (line.startsWith("branch refs/heads/")) {
branchName = line.replace("branch refs/heads/", "");
}
}
// Include worktrees that:
// 1. Are in our worktree folder (external or in-repo)
// 2. Have a posthog/ branch prefix (our naming convention)
const isInWorktreeFolder = worktreePath?.startsWith(worktreeFolderPath);
const isPosthogBranch = branchName?.startsWith("posthog/");
if (
worktreePath &&
branchName &&
(isInWorktreeFolder || isPosthogBranch)
) {
const worktreeName = path.basename(worktreePath);
worktrees.push({
worktreePath,
worktreeName,
branchName,
baseBranch: "",
createdAt: "",
});
}
}
return worktrees;
}
async isWorktree(repoPath: string): Promise<boolean> {
try {
const { stdout } = await execAsync(
"git rev-parse --is-inside-work-tree",
{ cwd: repoPath },
);
if (stdout.trim() !== "true") {
return false;
}
// Check if there's a .git file (worktrees have a .git file, not a .git directory)
const gitPath = path.join(repoPath, ".git");
const stat = await fs.stat(gitPath);
return stat.isFile(); // Worktrees have .git as a file, main repos have .git as a directory
} catch {
return false;
}
}
async getMainRepoPathFromWorktree(
worktreePath: string,
): Promise<string | null> {
try {
const gitFilePath = path.join(worktreePath, ".git");
const content = await fs.readFile(gitFilePath, "utf-8");
// The .git file in a worktree contains: gitdir: /path/to/main/.git/worktrees/name
const match = content.match(/gitdir:\s*(.+)/);
if (match) {
const gitDir = match[1].trim();
// Go up from .git/worktrees/name to get the main repo path
// The gitdir points to something like: /main/repo/.git/worktrees/worktree-name
const mainGitDir = path.resolve(gitDir, "..", "..", "..");
return mainGitDir;
}
return null;
} catch {
return null;
}
}
async cleanupOrphanedWorktrees(associatedWorktreePaths: string[]): Promise<{
deleted: string[];
errors: Array<{ path: string; error: string }>;
}> {
this.logger.info("Starting cleanup of orphaned worktrees");
const allWorktrees = await this.listWorktrees();
const deleted: string[] = [];
const errors: Array<{ path: string; error: string }> = [];
const associatedPathsSet = new Set(
associatedWorktreePaths.map((p) => path.resolve(p)),
);
for (const worktree of allWorktrees) {
const resolvedPath = path.resolve(worktree.worktreePath);
if (!associatedPathsSet.has(resolvedPath)) {
this.logger.info("Found orphaned worktree", {
path: worktree.worktreePath,
});
try {
await this.deleteWorktree(worktree.worktreePath);
deleted.push(worktree.worktreePath);
this.logger.info("Deleted orphaned worktree", {
path: worktree.worktreePath,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
errors.push({
path: worktree.worktreePath,
error: errorMessage,
});
this.logger.error("Failed to delete orphaned worktree", {
path: worktree.worktreePath,
error: errorMessage,
});
}
}
}
this.logger.info("Cleanup completed", {
deleted: deleted.length,
errors: errors.length,
});
return { deleted, errors };
}
}