mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 18:03:56 +00:00
Rename Foundry handoffs to tasks (#239)
* Restore foundry onboarding stack * Consolidate foundry rename * Create foundry tasks without prompts * Rename Foundry handoffs to tasks
This commit is contained in:
parent
d30cc0bcc8
commit
d75e8c31d1
281 changed files with 9242 additions and 4356 deletions
113
foundry/packages/backend/src/integrations/daytona/client.ts
Normal file
113
foundry/packages/backend/src/integrations/daytona/client.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { Daytona, type Image } from "@daytonaio/sdk";
|
||||
|
||||
export interface DaytonaSandbox {
|
||||
id: string;
|
||||
state?: string;
|
||||
snapshot?: string;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface DaytonaCreateSandboxOptions {
|
||||
image: string | Image;
|
||||
envVars?: Record<string, string>;
|
||||
labels?: Record<string, string>;
|
||||
autoStopInterval?: number;
|
||||
}
|
||||
|
||||
export interface DaytonaPreviewEndpoint {
|
||||
url: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface DaytonaClientOptions {
|
||||
apiUrl?: string;
|
||||
apiKey?: string;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
function normalizeApiUrl(input?: string): string | undefined {
|
||||
if (!input) return undefined;
|
||||
const trimmed = input.replace(/\/+$/, "");
|
||||
if (trimmed.endsWith("/api")) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed}/api`;
|
||||
}
|
||||
|
||||
export class DaytonaClient {
|
||||
private readonly daytona: Daytona;
|
||||
|
||||
constructor(options: DaytonaClientOptions) {
|
||||
const apiUrl = normalizeApiUrl(options.apiUrl);
|
||||
this.daytona = new Daytona({
|
||||
_experimental: {},
|
||||
...(apiUrl ? { apiUrl } : {}),
|
||||
...(options.apiKey ? { apiKey: options.apiKey } : {}),
|
||||
...(options.target ? { target: options.target } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async createSandbox(options: DaytonaCreateSandboxOptions): Promise<DaytonaSandbox> {
|
||||
const sandbox = await this.daytona.create({
|
||||
image: options.image,
|
||||
envVars: options.envVars,
|
||||
labels: options.labels,
|
||||
...(options.autoStopInterval !== undefined ? { autoStopInterval: options.autoStopInterval } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
id: sandbox.id,
|
||||
state: sandbox.state,
|
||||
snapshot: sandbox.snapshot,
|
||||
labels: (sandbox as any).labels,
|
||||
};
|
||||
}
|
||||
|
||||
async getSandbox(sandboxId: string): Promise<DaytonaSandbox> {
|
||||
const sandbox = await this.daytona.get(sandboxId);
|
||||
return {
|
||||
id: sandbox.id,
|
||||
state: sandbox.state,
|
||||
snapshot: sandbox.snapshot,
|
||||
labels: (sandbox as any).labels,
|
||||
};
|
||||
}
|
||||
|
||||
async startSandbox(sandboxId: string, timeoutSeconds?: number): Promise<void> {
|
||||
const sandbox = await this.daytona.get(sandboxId);
|
||||
await sandbox.start(timeoutSeconds);
|
||||
}
|
||||
|
||||
async stopSandbox(sandboxId: string, timeoutSeconds?: number): Promise<void> {
|
||||
const sandbox = await this.daytona.get(sandboxId);
|
||||
await sandbox.stop(timeoutSeconds);
|
||||
}
|
||||
|
||||
async deleteSandbox(sandboxId: string): Promise<void> {
|
||||
const sandbox = await this.daytona.get(sandboxId);
|
||||
await this.daytona.delete(sandbox);
|
||||
}
|
||||
|
||||
async executeCommand(sandboxId: string, command: string): Promise<{ exitCode: number; result: string }> {
|
||||
const sandbox = await this.daytona.get(sandboxId);
|
||||
const response = await sandbox.process.executeCommand(command);
|
||||
return {
|
||||
exitCode: response.exitCode,
|
||||
result: response.result,
|
||||
};
|
||||
}
|
||||
|
||||
async getPreviewEndpoint(sandboxId: string, port: number): Promise<DaytonaPreviewEndpoint> {
|
||||
const sandbox = await this.daytona.get(sandboxId);
|
||||
// Use signed preview URLs for server-to-sandbox communication.
|
||||
// The standard preview link may redirect to an interactive Auth0 flow from non-browser clients.
|
||||
// Signed preview URLs work for direct HTTP access.
|
||||
//
|
||||
// Request a longer-lived URL so sessions can run for several minutes without refresh.
|
||||
const preview = await sandbox.getSignedPreviewUrl(port, 6 * 60 * 60);
|
||||
return {
|
||||
url: preview.url,
|
||||
token: preview.token,
|
||||
};
|
||||
}
|
||||
}
|
||||
223
foundry/packages/backend/src/integrations/git-spice/index.ts
Normal file
223
foundry/packages/backend/src/integrations/git-spice/index.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 2 * 60_000;
|
||||
|
||||
interface SpiceCommand {
|
||||
command: string;
|
||||
prefix: string[];
|
||||
}
|
||||
|
||||
export interface SpiceStackEntry {
|
||||
branchName: string;
|
||||
parentBranch: string | null;
|
||||
}
|
||||
|
||||
function spiceCommands(): SpiceCommand[] {
|
||||
const explicit = process.env.HF_GIT_SPICE_BIN?.trim();
|
||||
const list: SpiceCommand[] = [];
|
||||
if (explicit) {
|
||||
list.push({ command: explicit, prefix: [] });
|
||||
}
|
||||
list.push({ command: "git-spice", prefix: [] });
|
||||
list.push({ command: "git", prefix: ["spice"] });
|
||||
return list;
|
||||
}
|
||||
|
||||
function commandLabel(cmd: SpiceCommand): string {
|
||||
return [cmd.command, ...cmd.prefix].join(" ");
|
||||
}
|
||||
|
||||
function looksMissing(error: unknown): boolean {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return detail.includes("ENOENT") || detail.includes("not a git command") || detail.includes("command not found");
|
||||
}
|
||||
|
||||
async function tryRun(repoPath: string, cmd: SpiceCommand, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
return await execFileAsync(cmd.command, [...cmd.prefix, ...args], {
|
||||
cwd: repoPath,
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024 * 8,
|
||||
env: {
|
||||
...process.env,
|
||||
NO_COLOR: "1",
|
||||
FORCE_COLOR: "0",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function pickCommand(repoPath: string): Promise<SpiceCommand | null> {
|
||||
for (const candidate of spiceCommands()) {
|
||||
try {
|
||||
await tryRun(repoPath, candidate, ["--help"]);
|
||||
return candidate;
|
||||
} catch (error) {
|
||||
if (looksMissing(error)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runSpice(repoPath: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
const cmd = await pickCommand(repoPath);
|
||||
if (!cmd) {
|
||||
throw new Error("git-spice is not available (set HF_GIT_SPICE_BIN or install git-spice)");
|
||||
}
|
||||
return await tryRun(repoPath, cmd, args);
|
||||
}
|
||||
|
||||
function parseLogJson(stdout: string): SpiceStackEntry[] {
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: SpiceStackEntry[] = [];
|
||||
|
||||
// `git-spice log ... --json` prints one JSON object per line.
|
||||
for (const line of trimmed.split("\n")) {
|
||||
const raw = line.trim();
|
||||
if (!raw.startsWith("{")) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(raw) as {
|
||||
name?: string;
|
||||
branch?: string;
|
||||
parent?: string | null;
|
||||
parentBranch?: string | null;
|
||||
};
|
||||
const branchName = (value.name ?? value.branch ?? "").trim();
|
||||
if (!branchName) {
|
||||
continue;
|
||||
}
|
||||
const parentRaw = value.parent ?? value.parentBranch ?? null;
|
||||
const parentBranch = parentRaw ? parentRaw.trim() || null : null;
|
||||
entries.push({ branchName, parentBranch });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return entries.filter((entry) => {
|
||||
if (seen.has(entry.branchName)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(entry.branchName);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function runFallbacks(repoPath: string, commands: string[][], errorContext: string): Promise<void> {
|
||||
const failures: string[] = [];
|
||||
for (const args of commands) {
|
||||
try {
|
||||
await runSpice(repoPath, args);
|
||||
return;
|
||||
} catch (error) {
|
||||
failures.push(`${args.join(" ")} :: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`${errorContext}. attempts=${failures.join(" | ")}`);
|
||||
}
|
||||
|
||||
export async function gitSpiceAvailable(repoPath: string): Promise<boolean> {
|
||||
return (await pickCommand(repoPath)) !== null;
|
||||
}
|
||||
|
||||
export async function gitSpiceListStack(repoPath: string): Promise<SpiceStackEntry[]> {
|
||||
try {
|
||||
const { stdout } = await runSpice(repoPath, ["log", "short", "--all", "--json", "--no-cr-status", "--no-prompt"]);
|
||||
return parseLogJson(stdout);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function gitSpiceSyncRepo(repoPath: string): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["repo", "sync", "--restack", "--no-prompt"],
|
||||
["repo", "sync", "--restack"],
|
||||
["repo", "sync"],
|
||||
],
|
||||
"git-spice repo sync failed",
|
||||
);
|
||||
}
|
||||
|
||||
export async function gitSpiceRestackRepo(repoPath: string): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["repo", "restack", "--no-prompt"],
|
||||
["repo", "restack"],
|
||||
],
|
||||
"git-spice repo restack failed",
|
||||
);
|
||||
}
|
||||
|
||||
export async function gitSpiceRestackSubtree(repoPath: string, branchName: string): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["upstack", "restack", "--branch", branchName, "--no-prompt"],
|
||||
["upstack", "restack", "--branch", branchName],
|
||||
["branch", "restack", "--branch", branchName, "--no-prompt"],
|
||||
["branch", "restack", "--branch", branchName],
|
||||
],
|
||||
`git-spice restack subtree failed for ${branchName}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function gitSpiceRebaseBranch(repoPath: string, branchName: string): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["branch", "restack", "--branch", branchName, "--no-prompt"],
|
||||
["branch", "restack", "--branch", branchName],
|
||||
],
|
||||
`git-spice branch restack failed for ${branchName}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function gitSpiceReparentBranch(repoPath: string, branchName: string, parentBranch: string): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["upstack", "onto", "--branch", branchName, parentBranch, "--no-prompt"],
|
||||
["upstack", "onto", "--branch", branchName, parentBranch],
|
||||
["branch", "onto", "--branch", branchName, parentBranch, "--no-prompt"],
|
||||
["branch", "onto", "--branch", branchName, parentBranch],
|
||||
],
|
||||
`git-spice reparent failed for ${branchName} -> ${parentBranch}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function gitSpiceTrackBranch(repoPath: string, branchName: string, parentBranch: string): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["branch", "track", branchName, "--base", parentBranch, "--no-prompt"],
|
||||
["branch", "track", branchName, "--base", parentBranch],
|
||||
],
|
||||
`git-spice track failed for ${branchName}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeBaseBranchName(ref: string): string {
|
||||
const trimmed = ref.trim();
|
||||
if (!trimmed) {
|
||||
return "main";
|
||||
}
|
||||
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
|
||||
}
|
||||
|
||||
export function describeSpiceCommandForLogs(repoPath: string): Promise<string | null> {
|
||||
return pickCommand(repoPath).then((cmd) => (cmd ? commandLabel(cmd) : null));
|
||||
}
|
||||
260
foundry/packages/backend/src/integrations/git/index.ts
Normal file
260
foundry/packages/backend/src/integrations/git/index.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_GIT_FETCH_TIMEOUT_MS = 2 * 60_000;
|
||||
const DEFAULT_GIT_CLONE_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
function resolveGithubToken(): string | null {
|
||||
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.HF_GITHUB_TOKEN ?? process.env.HF_GH_TOKEN ?? null;
|
||||
if (!token) return null;
|
||||
const trimmed = token.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
let cachedAskpassPath: string | null = null;
|
||||
function ensureAskpassScript(): string {
|
||||
if (cachedAskpassPath) {
|
||||
return cachedAskpassPath;
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(resolve(tmpdir(), "foundry-git-askpass-"));
|
||||
const path = resolve(dir, "askpass.sh");
|
||||
|
||||
// Git invokes $GIT_ASKPASS with the prompt string as argv[1]. Provide both username and password.
|
||||
// We avoid embedding the token in this file; it is read from env at runtime.
|
||||
const content = [
|
||||
"#!/bin/sh",
|
||||
'prompt="$1"',
|
||||
// Prefer GH_TOKEN/GITHUB_TOKEN but support HF_* aliases too.
|
||||
'token="${GH_TOKEN:-${GITHUB_TOKEN:-${HF_GITHUB_TOKEN:-${HF_GH_TOKEN:-}}}}"',
|
||||
'case "$prompt" in',
|
||||
' *Username*) echo "x-access-token" ;;',
|
||||
' *Password*) echo "$token" ;;',
|
||||
' *) echo "" ;;',
|
||||
"esac",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
writeFileSync(path, content, "utf8");
|
||||
chmodSync(path, 0o700);
|
||||
cachedAskpassPath = path;
|
||||
return path;
|
||||
}
|
||||
|
||||
function gitEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
|
||||
env.GIT_TERMINAL_PROMPT = "0";
|
||||
|
||||
const token = resolveGithubToken();
|
||||
if (token) {
|
||||
env.GIT_ASKPASS = ensureAskpassScript();
|
||||
// Some tooling expects these vars; keep them aligned.
|
||||
env.GITHUB_TOKEN = env.GITHUB_TOKEN || token;
|
||||
env.GH_TOKEN = env.GH_TOKEN || token;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
export interface BranchSnapshot {
|
||||
branchName: string;
|
||||
commitSha: string;
|
||||
}
|
||||
|
||||
export async function fetch(repoPath: string): Promise<void> {
|
||||
await execFileAsync("git", ["-C", repoPath, "fetch", "--prune"], {
|
||||
timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS,
|
||||
env: gitEnv(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function revParse(repoPath: string, ref: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync("git", ["-C", repoPath, "rev-parse", ref], { env: gitEnv() });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
export async function validateRemote(remoteUrl: string): Promise<void> {
|
||||
const remote = remoteUrl.trim();
|
||||
if (!remote) {
|
||||
throw new Error("remoteUrl is required");
|
||||
}
|
||||
try {
|
||||
await execFileAsync("git", ["ls-remote", "--exit-code", remote, "HEAD"], {
|
||||
// This command does not need repo context. Running from a neutral directory
|
||||
// avoids inheriting broken worktree .git indirection inside dev containers.
|
||||
cwd: tmpdir(),
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS,
|
||||
env: gitEnv(),
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`git remote validation failed: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isGitRepo(path: string): boolean {
|
||||
return existsSync(resolve(path, ".git"));
|
||||
}
|
||||
|
||||
export async function ensureCloned(remoteUrl: string, targetPath: string): Promise<void> {
|
||||
const remote = remoteUrl.trim();
|
||||
if (!remote) {
|
||||
throw new Error("remoteUrl is required");
|
||||
}
|
||||
|
||||
if (existsSync(targetPath)) {
|
||||
if (!isGitRepo(targetPath)) {
|
||||
throw new Error(`targetPath exists but is not a git repo: ${targetPath}`);
|
||||
}
|
||||
|
||||
// Keep origin aligned with the configured remote URL.
|
||||
await execFileAsync("git", ["-C", targetPath, "remote", "set-url", "origin", remote], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS,
|
||||
env: gitEnv(),
|
||||
});
|
||||
await fetch(targetPath);
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
await execFileAsync("git", ["clone", remote, targetPath], {
|
||||
maxBuffer: 1024 * 1024 * 8,
|
||||
timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS,
|
||||
env: gitEnv(),
|
||||
});
|
||||
await fetch(targetPath);
|
||||
}
|
||||
|
||||
export async function remoteDefaultBaseRef(repoPath: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("git", ["-C", repoPath, "symbolic-ref", "refs/remotes/origin/HEAD"], { env: gitEnv() });
|
||||
const ref = stdout.trim(); // refs/remotes/origin/main
|
||||
const match = ref.match(/^refs\/remotes\/(.+)$/);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
const candidates = ["origin/main", "origin/master", "main", "master"];
|
||||
for (const ref of candidates) {
|
||||
try {
|
||||
await execFileAsync("git", ["-C", repoPath, "rev-parse", "--verify", ref], { env: gitEnv() });
|
||||
return ref;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return "origin/main";
|
||||
}
|
||||
|
||||
export async function listRemoteBranches(repoPath: string): Promise<BranchSnapshot[]> {
|
||||
const { stdout } = await execFileAsync("git", ["-C", repoPath, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/remotes/origin"], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: gitEnv(),
|
||||
});
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => {
|
||||
const [refName, commitSha] = line.trim().split(/\s+/, 2);
|
||||
const short = (refName ?? "").trim();
|
||||
const branchName = short.replace(/^origin\//, "");
|
||||
return { branchName, commitSha: commitSha ?? "" };
|
||||
})
|
||||
.filter((row) => row.branchName.length > 0 && row.branchName !== "HEAD" && row.branchName !== "origin" && row.commitSha.length > 0);
|
||||
}
|
||||
|
||||
async function remoteBranchExists(repoPath: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync("git", ["-C", repoPath, "show-ref", "--verify", `refs/remotes/origin/${branchName}`], { env: gitEnv() });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureRemoteBranch(repoPath: string, branchName: string): Promise<void> {
|
||||
await fetch(repoPath);
|
||||
if (await remoteBranchExists(repoPath, branchName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseRef = await remoteDefaultBaseRef(repoPath);
|
||||
await execFileAsync("git", ["-C", repoPath, "push", "origin", `${baseRef}:refs/heads/${branchName}`], {
|
||||
maxBuffer: 1024 * 1024 * 2,
|
||||
env: gitEnv(),
|
||||
});
|
||||
await fetch(repoPath);
|
||||
}
|
||||
|
||||
export async function diffStatForBranch(repoPath: string, branchName: string): Promise<string> {
|
||||
try {
|
||||
const baseRef = await remoteDefaultBaseRef(repoPath);
|
||||
const headRef = `origin/${branchName}`;
|
||||
const { stdout } = await execFileAsync("git", ["-C", repoPath, "diff", "--shortstat", `${baseRef}...${headRef}`], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: gitEnv(),
|
||||
});
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
return "+0/-0";
|
||||
}
|
||||
const insertMatch = trimmed.match(/(\d+)\s+insertion/);
|
||||
const deleteMatch = trimmed.match(/(\d+)\s+deletion/);
|
||||
const insertions = insertMatch ? insertMatch[1] : "0";
|
||||
const deletions = deleteMatch ? deleteMatch[1] : "0";
|
||||
return `+${insertions}/-${deletions}`;
|
||||
} catch {
|
||||
return "+0/-0";
|
||||
}
|
||||
}
|
||||
|
||||
export async function conflictsWithMain(repoPath: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
const baseRef = await remoteDefaultBaseRef(repoPath);
|
||||
const headRef = `origin/${branchName}`;
|
||||
// Use merge-tree (git 2.38+) for a clean conflict check.
|
||||
try {
|
||||
await execFileAsync("git", ["-C", repoPath, "merge-tree", "--write-tree", "--no-messages", baseRef, headRef], { env: gitEnv() });
|
||||
// If merge-tree exits 0, no conflicts. Non-zero exit means conflicts.
|
||||
return false;
|
||||
} catch {
|
||||
// merge-tree exits non-zero when there are conflicts
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOriginOwner(repoPath: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("git", ["-C", repoPath, "remote", "get-url", "origin"], { env: gitEnv() });
|
||||
const url = stdout.trim();
|
||||
// Handle SSH: git@github.com:owner/repo.git
|
||||
const sshMatch = url.match(/[:\/]([^\/]+)\/[^\/]+(?:\.git)?$/);
|
||||
if (sshMatch) {
|
||||
return sshMatch[1] ?? "";
|
||||
}
|
||||
// Handle HTTPS: https://github.com/owner/repo.git
|
||||
const httpsMatch = url.match(/\/\/[^\/]+\/([^\/]+)\//);
|
||||
if (httpsMatch) {
|
||||
return httpsMatch[1] ?? "";
|
||||
}
|
||||
return "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
231
foundry/packages/backend/src/integrations/github/index.ts
Normal file
231
foundry/packages/backend/src/integrations/github/index.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface PullRequestSnapshot {
|
||||
number: number;
|
||||
headRefName: string;
|
||||
state: string;
|
||||
title: string;
|
||||
url: string;
|
||||
author: string;
|
||||
isDraft: boolean;
|
||||
ciStatus: string | null;
|
||||
reviewStatus: string | null;
|
||||
reviewer: string | null;
|
||||
}
|
||||
|
||||
interface GhPrListItem {
|
||||
number: number;
|
||||
headRefName: string;
|
||||
state: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
author?: { login?: string };
|
||||
isDraft?: boolean;
|
||||
statusCheckRollup?: Array<{
|
||||
state?: string;
|
||||
status?: string;
|
||||
conclusion?: string;
|
||||
__typename?: string;
|
||||
}>;
|
||||
reviews?: Array<{
|
||||
state?: string;
|
||||
author?: { login?: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseCiStatus(checks: GhPrListItem["statusCheckRollup"]): string | null {
|
||||
if (!checks || checks.length === 0) return null;
|
||||
|
||||
let total = 0;
|
||||
let successes = 0;
|
||||
let hasRunning = false;
|
||||
|
||||
for (const check of checks) {
|
||||
total++;
|
||||
const conclusion = check.conclusion?.toUpperCase();
|
||||
const state = check.state?.toUpperCase();
|
||||
const status = check.status?.toUpperCase();
|
||||
|
||||
if (conclusion === "SUCCESS" || state === "SUCCESS") {
|
||||
successes++;
|
||||
} else if (status === "IN_PROGRESS" || status === "QUEUED" || status === "PENDING" || state === "PENDING") {
|
||||
hasRunning = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRunning && successes < total) {
|
||||
return "running";
|
||||
}
|
||||
|
||||
return `${successes}/${total}`;
|
||||
}
|
||||
|
||||
function parseReviewStatus(reviews: GhPrListItem["reviews"]): { status: string | null; reviewer: string | null } {
|
||||
if (!reviews || reviews.length === 0) {
|
||||
return { status: null, reviewer: null };
|
||||
}
|
||||
|
||||
// Build a map of latest review per author
|
||||
const latestByAuthor = new Map<string, { state: string; login: string }>();
|
||||
for (const review of reviews) {
|
||||
const login = review.author?.login ?? "unknown";
|
||||
const state = review.state?.toUpperCase() ?? "";
|
||||
if (state === "COMMENTED") continue; // Skip comments, only track actionable reviews
|
||||
latestByAuthor.set(login, { state, login });
|
||||
}
|
||||
|
||||
// Check for CHANGES_REQUESTED first (takes priority), then APPROVED
|
||||
for (const [, entry] of latestByAuthor) {
|
||||
if (entry.state === "CHANGES_REQUESTED") {
|
||||
return { status: "CHANGES_REQUESTED", reviewer: entry.login };
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, entry] of latestByAuthor) {
|
||||
if (entry.state === "APPROVED") {
|
||||
return { status: "APPROVED", reviewer: entry.login };
|
||||
}
|
||||
}
|
||||
|
||||
// If there are reviews but none are APPROVED or CHANGES_REQUESTED
|
||||
if (latestByAuthor.size > 0) {
|
||||
const first = latestByAuthor.values().next().value;
|
||||
return { status: "PENDING", reviewer: first?.login ?? null };
|
||||
}
|
||||
|
||||
return { status: null, reviewer: null };
|
||||
}
|
||||
|
||||
function snapshotFromGhItem(item: GhPrListItem): PullRequestSnapshot {
|
||||
const { status: reviewStatus, reviewer } = parseReviewStatus(item.reviews);
|
||||
return {
|
||||
number: item.number,
|
||||
headRefName: item.headRefName,
|
||||
state: item.state,
|
||||
title: item.title,
|
||||
url: item.url ?? "",
|
||||
author: item.author?.login ?? "",
|
||||
isDraft: item.isDraft ?? false,
|
||||
ciStatus: parseCiStatus(item.statusCheckRollup),
|
||||
reviewStatus,
|
||||
reviewer,
|
||||
};
|
||||
}
|
||||
|
||||
const PR_JSON_FIELDS = "number,headRefName,state,title,url,author,isDraft,statusCheckRollup,reviews";
|
||||
|
||||
export async function listPullRequests(repoPath: string): Promise<PullRequestSnapshot[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("gh", ["pr", "list", "--json", PR_JSON_FIELDS, "--limit", "200"], { maxBuffer: 1024 * 1024 * 4, cwd: repoPath });
|
||||
|
||||
const parsed = JSON.parse(stdout) as GhPrListItem[];
|
||||
|
||||
return parsed.map((item) => {
|
||||
// Handle fork PRs where headRefName may contain "owner:branch"
|
||||
const headRefName = item.headRefName.includes(":") ? (item.headRefName.split(":").pop() ?? item.headRefName) : item.headRefName;
|
||||
|
||||
return snapshotFromGhItem({ ...item, headRefName });
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPrInfo(repoPath: string, branchName: string): Promise<PullRequestSnapshot | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", PR_JSON_FIELDS], { maxBuffer: 1024 * 1024 * 4, cwd: repoPath });
|
||||
|
||||
const item = JSON.parse(stdout) as GhPrListItem;
|
||||
return snapshotFromGhItem(item);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPr(repoPath: string, headBranch: string, title: string, body?: string): Promise<{ number: number; url: string }> {
|
||||
const args = ["pr", "create", "--title", title, "--head", headBranch];
|
||||
if (body) {
|
||||
args.push("--body", body);
|
||||
} else {
|
||||
args.push("--body", "");
|
||||
}
|
||||
|
||||
const { stdout } = await execFileAsync("gh", args, {
|
||||
maxBuffer: 1024 * 1024,
|
||||
cwd: repoPath,
|
||||
});
|
||||
|
||||
// gh pr create outputs the PR URL on success
|
||||
const url = stdout.trim();
|
||||
// Extract PR number from URL: https://github.com/owner/repo/pull/123
|
||||
const numberMatch = url.match(/\/pull\/(\d+)/);
|
||||
const number = numberMatch ? parseInt(numberMatch[1]!, 10) : 0;
|
||||
|
||||
return { number, url };
|
||||
}
|
||||
|
||||
export async function starRepository(repoFullName: string): Promise<void> {
|
||||
try {
|
||||
await execFileAsync("gh", ["api", "--method", "PUT", `user/starred/${repoFullName}`], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : `Failed to star GitHub repository ${repoFullName}. Ensure GitHub auth is configured for the backend.`;
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllowedMergeMethod(repoPath: string): Promise<"squash" | "rebase" | "merge"> {
|
||||
try {
|
||||
// Get the repo owner/name from gh
|
||||
const { stdout: repoJson } = await execFileAsync("gh", ["repo", "view", "--json", "owner,name"], { cwd: repoPath });
|
||||
const repo = JSON.parse(repoJson) as { owner: { login: string }; name: string };
|
||||
const repoFullName = `${repo.owner.login}/${repo.name}`;
|
||||
|
||||
const { stdout } = await execFileAsync("gh", ["api", `repos/${repoFullName}`, "--jq", ".allow_squash_merge, .allow_rebase_merge, .allow_merge_commit"], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
cwd: repoPath,
|
||||
});
|
||||
|
||||
const lines = stdout.trim().split("\n");
|
||||
const allowSquash = lines[0]?.trim() === "true";
|
||||
const allowRebase = lines[1]?.trim() === "true";
|
||||
const allowMerge = lines[2]?.trim() === "true";
|
||||
|
||||
if (allowSquash) return "squash";
|
||||
if (allowRebase) return "rebase";
|
||||
if (allowMerge) return "merge";
|
||||
return "squash";
|
||||
} catch {
|
||||
return "squash";
|
||||
}
|
||||
}
|
||||
|
||||
export async function mergePr(repoPath: string, prNumber: number): Promise<void> {
|
||||
const method = await getAllowedMergeMethod(repoPath);
|
||||
await execFileAsync("gh", ["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"], { cwd: repoPath });
|
||||
}
|
||||
|
||||
export async function isPrMerged(repoPath: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", "state"], { cwd: repoPath });
|
||||
const parsed = JSON.parse(stdout) as { state: string };
|
||||
return parsed.state.toUpperCase() === "MERGED";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPrTitle(repoPath: string, branchName: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", "title"], { cwd: repoPath });
|
||||
const parsed = JSON.parse(stdout) as { title: string };
|
||||
return parsed.title;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
140
foundry/packages/backend/src/integrations/graphite/index.ts
Normal file
140
foundry/packages/backend/src/integrations/graphite/index.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function graphiteAvailable(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync("gt", ["trunk"], { cwd: repoPath });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function graphiteGet(repoPath: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync("gt", ["get", branchName], { cwd: repoPath });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function graphiteCreateBranch(repoPath: string, branchName: string): Promise<void> {
|
||||
await execFileAsync("gt", ["create", branchName], { cwd: repoPath });
|
||||
}
|
||||
|
||||
export async function graphiteCheckout(repoPath: string, branchName: string): Promise<void> {
|
||||
await execFileAsync("gt", ["checkout", branchName], { cwd: repoPath });
|
||||
}
|
||||
|
||||
export async function graphiteSubmit(repoPath: string): Promise<void> {
|
||||
await execFileAsync("gt", ["submit", "--no-edit"], { cwd: repoPath });
|
||||
}
|
||||
|
||||
export async function graphiteMergeBranch(repoPath: string, branchName: string): Promise<void> {
|
||||
await execFileAsync("gt", ["merge", branchName], { cwd: repoPath });
|
||||
}
|
||||
|
||||
export async function graphiteAbandon(repoPath: string, branchName: string): Promise<void> {
|
||||
await execFileAsync("gt", ["abandon", branchName], { cwd: repoPath });
|
||||
}
|
||||
|
||||
export interface GraphiteStackEntry {
|
||||
branchName: string;
|
||||
parentBranch: string | null;
|
||||
}
|
||||
|
||||
export async function graphiteGetStack(repoPath: string): Promise<GraphiteStackEntry[]> {
|
||||
try {
|
||||
// Try JSON output first
|
||||
const { stdout } = await execFileAsync("gt", ["log", "--json"], {
|
||||
cwd: repoPath,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(stdout) as Array<{
|
||||
branch?: string;
|
||||
name?: string;
|
||||
parent?: string;
|
||||
parentBranch?: string;
|
||||
}>;
|
||||
|
||||
return parsed.map((entry) => ({
|
||||
branchName: entry.branch ?? entry.name ?? "",
|
||||
parentBranch: entry.parent ?? entry.parentBranch ?? null,
|
||||
}));
|
||||
} catch {
|
||||
// Fall back to text parsing of `gt log`
|
||||
try {
|
||||
const { stdout } = await execFileAsync("gt", ["log"], {
|
||||
cwd: repoPath,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
const entries: GraphiteStackEntry[] = [];
|
||||
const lines = stdout.split("\n").filter((l) => l.trim().length > 0);
|
||||
|
||||
// Parse indented tree output: each line has tree chars (|, /, \, -, etc.)
|
||||
// followed by branch names. Build parent-child from indentation level.
|
||||
const branchStack: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Strip ANSI color codes
|
||||
const clean = line.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
// Extract branch name: skip tree characters and whitespace
|
||||
const branchMatch = clean.match(/[│├└─|/\\*\s]*(?:◉|○|●)?\s*(.+)/);
|
||||
if (!branchMatch) continue;
|
||||
|
||||
const branchName = branchMatch[1]!.trim();
|
||||
if (!branchName || branchName.startsWith("(") || branchName === "") continue;
|
||||
|
||||
// Determine indentation level by counting leading whitespace/tree chars
|
||||
const indent = clean.search(/[a-zA-Z0-9]/);
|
||||
const level = Math.max(0, Math.floor(indent / 2));
|
||||
|
||||
// Trim stack to current level
|
||||
while (branchStack.length > level) {
|
||||
branchStack.pop();
|
||||
}
|
||||
|
||||
const parentBranch = branchStack.length > 0 ? (branchStack[branchStack.length - 1] ?? null) : null;
|
||||
|
||||
entries.push({ branchName, parentBranch });
|
||||
branchStack.push(branchName);
|
||||
}
|
||||
|
||||
return entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function graphiteGetParent(repoPath: string, branchName: string): Promise<string | null> {
|
||||
try {
|
||||
// Try `gt get <branchName>` to see parent info
|
||||
const { stdout } = await execFileAsync("gt", ["get", branchName], {
|
||||
cwd: repoPath,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
// Parse output for parent branch reference
|
||||
const parentMatch = stdout.match(/parent:\s*(\S+)/i);
|
||||
if (parentMatch) {
|
||||
return parentMatch[1] ?? null;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to stack-based lookup
|
||||
}
|
||||
|
||||
// Fall back to stack info
|
||||
try {
|
||||
const stack = await graphiteGetStack(repoPath);
|
||||
const entry = stack.find((e) => e.branchName === branchName);
|
||||
return entry?.parentBranch ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,418 @@
|
|||
import type { AgentType } from "@sandbox-agent/foundry-shared";
|
||||
import type {
|
||||
ListEventsRequest,
|
||||
ListPage,
|
||||
ListPageRequest,
|
||||
ProcessCreateRequest,
|
||||
ProcessInfo,
|
||||
ProcessLogFollowQuery,
|
||||
ProcessLogsResponse,
|
||||
ProcessSignalQuery,
|
||||
SessionEvent,
|
||||
SessionPersistDriver,
|
||||
SessionRecord,
|
||||
} from "sandbox-agent";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
|
||||
export type AgentId = AgentType | "opencode";
|
||||
|
||||
export interface SandboxSession {
|
||||
id: string;
|
||||
status: "running" | "idle" | "error";
|
||||
}
|
||||
|
||||
export interface SandboxSessionCreateRequest {
|
||||
prompt?: string;
|
||||
cwd?: string;
|
||||
agent?: AgentId;
|
||||
}
|
||||
|
||||
export interface SandboxSessionPromptRequest {
|
||||
sessionId: string;
|
||||
prompt: string;
|
||||
notification?: boolean;
|
||||
}
|
||||
|
||||
export interface SandboxAgentClientOptions {
|
||||
endpoint: string;
|
||||
token?: string;
|
||||
agent?: AgentId;
|
||||
persist?: SessionPersistDriver;
|
||||
}
|
||||
|
||||
const DEFAULT_AGENT: AgentId = "codex";
|
||||
|
||||
function modeIdForAgent(agent: AgentId): string | null {
|
||||
switch (agent) {
|
||||
case "codex":
|
||||
return "full-access";
|
||||
case "claude":
|
||||
return "acceptEdits";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStatusFromMessage(payload: unknown): SandboxSession["status"] | null {
|
||||
if (payload && typeof payload === "object") {
|
||||
const envelope = payload as {
|
||||
error?: unknown;
|
||||
method?: unknown;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
const maybeError = envelope.error;
|
||||
if (maybeError) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
if (envelope.result && typeof envelope.result === "object") {
|
||||
const stopReason = (envelope.result as { stopReason?: unknown }).stopReason;
|
||||
if (typeof stopReason === "string" && stopReason.length > 0) {
|
||||
return "idle";
|
||||
}
|
||||
}
|
||||
|
||||
const method = envelope.method;
|
||||
if (typeof method === "string") {
|
||||
const lowered = method.toLowerCase();
|
||||
if (lowered.includes("error") || lowered.includes("failed")) {
|
||||
return "error";
|
||||
}
|
||||
if (lowered.includes("ended") || lowered.includes("complete") || lowered.includes("stopped")) {
|
||||
return "idle";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export class SandboxAgentClient {
|
||||
readonly endpoint: string;
|
||||
readonly token?: string;
|
||||
readonly agent: AgentId;
|
||||
readonly persist?: SessionPersistDriver;
|
||||
private sdkPromise?: Promise<SandboxAgent>;
|
||||
private readonly statusBySessionId = new Map<string, SandboxSession["status"]>();
|
||||
|
||||
constructor(options: SandboxAgentClientOptions) {
|
||||
this.endpoint = options.endpoint.replace(/\/$/, "");
|
||||
this.token = options.token;
|
||||
this.agent = options.agent ?? DEFAULT_AGENT;
|
||||
this.persist = options.persist;
|
||||
}
|
||||
|
||||
private async sdk(): Promise<SandboxAgent> {
|
||||
if (!this.sdkPromise) {
|
||||
this.sdkPromise = SandboxAgent.connect({
|
||||
baseUrl: this.endpoint,
|
||||
token: this.token,
|
||||
persist: this.persist,
|
||||
});
|
||||
}
|
||||
|
||||
return this.sdkPromise;
|
||||
}
|
||||
|
||||
private setStatus(sessionId: string, status: SandboxSession["status"]): void {
|
||||
this.statusBySessionId.set(sessionId, status);
|
||||
}
|
||||
|
||||
private isLikelyPromptTimeout(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const lowered = message.toLowerCase();
|
||||
// sandbox-agent server times out long-running ACP prompts and returns a 504-like error.
|
||||
return lowered.includes("timeout waiting for agent response") || lowered.includes("timed out waiting for agent response") || lowered.includes("504");
|
||||
}
|
||||
|
||||
async createSession(request: string | SandboxSessionCreateRequest): Promise<SandboxSession> {
|
||||
const normalized: SandboxSessionCreateRequest = typeof request === "string" ? { prompt: request } : request;
|
||||
const sdk = await this.sdk();
|
||||
// Do not wrap createSession in a local Promise.race timeout. The underlying SDK
|
||||
// call is not abortable, so local timeout races create overlapping ACP requests and
|
||||
// can produce duplicate/orphaned sessions while the original request is still running.
|
||||
const session = await sdk.createSession({
|
||||
agent: normalized.agent ?? this.agent,
|
||||
sessionInit: {
|
||||
cwd: normalized.cwd ?? "/",
|
||||
mcpServers: [],
|
||||
},
|
||||
});
|
||||
const modeId = modeIdForAgent(normalized.agent ?? this.agent);
|
||||
|
||||
// Codex defaults to a restrictive "read-only" preset in some environments.
|
||||
// Foundry automation needs edits, command execution, and network access.
|
||||
// access (git push / PR creation). Use full-access where supported.
|
||||
//
|
||||
// If the agent doesn't support session modes, ignore.
|
||||
//
|
||||
// Do this in the background: ACP mode updates can occasionally time out (504),
|
||||
// and waiting here can stall session creation long enough to trip task init
|
||||
// step timeouts even though the session itself was created.
|
||||
if (modeId) {
|
||||
void session.rawSend("session/set_mode", { modeId }).catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
|
||||
const prompt = normalized.prompt?.trim();
|
||||
if (!prompt) {
|
||||
this.setStatus(session.id, "idle");
|
||||
return {
|
||||
id: session.id,
|
||||
status: "idle",
|
||||
};
|
||||
}
|
||||
|
||||
// Fire the first turn in the background. We intentionally do not await this:
|
||||
// session creation must remain fast, and we observe completion via events/stopReason.
|
||||
//
|
||||
// Note: sandbox-agent's ACP adapter for Codex may take >2 minutes to respond.
|
||||
// sandbox-agent can return a timeout error (504) even though the agent continues
|
||||
// running. Treat that timeout as non-fatal and keep polling events.
|
||||
void session
|
||||
.prompt([{ type: "text", text: prompt }])
|
||||
.then(() => {
|
||||
this.setStatus(session.id, "idle");
|
||||
})
|
||||
.catch((err) => {
|
||||
if (this.isLikelyPromptTimeout(err)) {
|
||||
this.setStatus(session.id, "running");
|
||||
return;
|
||||
}
|
||||
this.setStatus(session.id, "error");
|
||||
});
|
||||
|
||||
this.setStatus(session.id, "running");
|
||||
return {
|
||||
id: session.id,
|
||||
status: "running",
|
||||
};
|
||||
}
|
||||
|
||||
async createSessionNoTask(dir: string): Promise<SandboxSession> {
|
||||
return this.createSession({
|
||||
cwd: dir,
|
||||
});
|
||||
}
|
||||
|
||||
async listSessions(request: ListPageRequest = {}): Promise<ListPage<SessionRecord>> {
|
||||
const sdk = await this.sdk();
|
||||
const page = await sdk.listSessions(request);
|
||||
return {
|
||||
items: page.items.map((session) => session.toRecord()),
|
||||
nextCursor: page.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
async listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>> {
|
||||
const sdk = await this.sdk();
|
||||
return sdk.getEvents(request);
|
||||
}
|
||||
|
||||
async createProcess(request: ProcessCreateRequest): Promise<ProcessInfo> {
|
||||
const sdk = await this.sdk();
|
||||
return await sdk.createProcess(request);
|
||||
}
|
||||
|
||||
async listProcesses(): Promise<{ processes: ProcessInfo[] }> {
|
||||
const sdk = await this.sdk();
|
||||
return await sdk.listProcesses();
|
||||
}
|
||||
|
||||
async getProcessLogs(processId: string, query: ProcessLogFollowQuery = {}): Promise<ProcessLogsResponse> {
|
||||
const sdk = await this.sdk();
|
||||
return await sdk.getProcessLogs(processId, query);
|
||||
}
|
||||
|
||||
async stopProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo> {
|
||||
const sdk = await this.sdk();
|
||||
return await sdk.stopProcess(processId, query);
|
||||
}
|
||||
|
||||
async killProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo> {
|
||||
const sdk = await this.sdk();
|
||||
return await sdk.killProcess(processId, query);
|
||||
}
|
||||
|
||||
async deleteProcess(processId: string): Promise<void> {
|
||||
const sdk = await this.sdk();
|
||||
await sdk.deleteProcess(processId);
|
||||
}
|
||||
|
||||
async sendPrompt(request: SandboxSessionPromptRequest): Promise<void> {
|
||||
const sdk = await this.sdk();
|
||||
const existing = await sdk.getSession(request.sessionId);
|
||||
if (!existing) {
|
||||
throw new Error(`session '${request.sessionId}' not found`);
|
||||
}
|
||||
|
||||
const session = await sdk.resumeSession(request.sessionId);
|
||||
const modeId = modeIdForAgent(this.agent);
|
||||
// Keep mode update best-effort and non-blocking for the same reason as createSession.
|
||||
if (modeId) {
|
||||
void session.rawSend("session/set_mode", { modeId }).catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
const text = request.prompt.trim();
|
||||
if (!text) return;
|
||||
|
||||
// sandbox-agent's Session.send(notification=true) forwards an extNotification with
|
||||
// method "session/prompt", which some agents (e.g. codex-acp) do not implement.
|
||||
// Use Session.prompt and treat notification=true as "fire-and-forget".
|
||||
const fireAndForget = request.notification ?? true;
|
||||
if (fireAndForget) {
|
||||
void session
|
||||
.prompt([{ type: "text", text }])
|
||||
.then(() => {
|
||||
this.setStatus(request.sessionId, "idle");
|
||||
})
|
||||
.catch((err) => {
|
||||
if (this.isLikelyPromptTimeout(err)) {
|
||||
this.setStatus(request.sessionId, "running");
|
||||
return;
|
||||
}
|
||||
this.setStatus(request.sessionId, "error");
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await session.prompt([{ type: "text", text }]);
|
||||
this.setStatus(request.sessionId, "idle");
|
||||
} catch (err) {
|
||||
if (this.isLikelyPromptTimeout(err)) {
|
||||
this.setStatus(request.sessionId, "running");
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
this.setStatus(request.sessionId, "running");
|
||||
}
|
||||
|
||||
async cancelSession(sessionId: string): Promise<void> {
|
||||
const sdk = await this.sdk();
|
||||
const existing = await sdk.getSession(sessionId);
|
||||
if (!existing) {
|
||||
throw new Error(`session '${sessionId}' not found`);
|
||||
}
|
||||
|
||||
const session = await sdk.resumeSession(sessionId);
|
||||
await session.rawSend("session/cancel", {});
|
||||
this.setStatus(sessionId, "idle");
|
||||
}
|
||||
|
||||
async destroySession(sessionId: string): Promise<void> {
|
||||
const sdk = await this.sdk();
|
||||
await sdk.destroySession(sessionId);
|
||||
this.setStatus(sessionId, "idle");
|
||||
}
|
||||
|
||||
async sessionStatus(sessionId: string): Promise<SandboxSession> {
|
||||
const cached = this.statusBySessionId.get(sessionId);
|
||||
if (cached && cached !== "running") {
|
||||
return { id: sessionId, status: cached };
|
||||
}
|
||||
|
||||
const sdk = await this.sdk();
|
||||
const session = await sdk.getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
this.setStatus(sessionId, "error");
|
||||
return { id: sessionId, status: "error" };
|
||||
}
|
||||
|
||||
const record = session.toRecord();
|
||||
if (record.destroyedAt) {
|
||||
this.setStatus(sessionId, "idle");
|
||||
return { id: sessionId, status: "idle" };
|
||||
}
|
||||
|
||||
const events = await sdk.getEvents({
|
||||
sessionId,
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
for (let i = events.items.length - 1; i >= 0; i--) {
|
||||
const item = events.items[i];
|
||||
if (!item) continue;
|
||||
const status = normalizeStatusFromMessage(item.payload);
|
||||
if (status) {
|
||||
this.setStatus(sessionId, status);
|
||||
return { id: sessionId, status };
|
||||
}
|
||||
}
|
||||
|
||||
this.setStatus(sessionId, "running");
|
||||
return { id: sessionId, status: "running" };
|
||||
}
|
||||
|
||||
async killSessionsInDirectory(dir: string): Promise<void> {
|
||||
const sdk = await this.sdk();
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const page = await sdk.listSessions({
|
||||
cursor,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
for (const session of page.items) {
|
||||
const initCwd = session.toRecord().sessionInit?.cwd;
|
||||
if (initCwd !== dir) {
|
||||
continue;
|
||||
}
|
||||
await sdk.destroySession(session.id);
|
||||
this.statusBySessionId.delete(session.id);
|
||||
}
|
||||
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
}
|
||||
|
||||
async generateCommitMessage(dir: string, spec: string, task: string): Promise<string> {
|
||||
const prompt = [
|
||||
"Generate a conventional commit message for the following changes.",
|
||||
"Return ONLY the commit message, no explanation or markdown formatting.",
|
||||
"",
|
||||
`Task: ${task}`,
|
||||
"",
|
||||
`Spec/diff:\n${spec}`,
|
||||
].join("\n");
|
||||
|
||||
const sdk = await this.sdk();
|
||||
const session = await sdk.createSession({
|
||||
agent: this.agent,
|
||||
sessionInit: {
|
||||
cwd: dir,
|
||||
mcpServers: [],
|
||||
},
|
||||
});
|
||||
|
||||
await session.prompt([{ type: "text", text: prompt }]);
|
||||
this.setStatus(session.id, "idle");
|
||||
|
||||
const events = await sdk.getEvents({
|
||||
sessionId: session.id,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
for (let i = events.items.length - 1; i >= 0; i--) {
|
||||
const event = events.items[i];
|
||||
if (!event) continue;
|
||||
if (event.sender !== "agent") continue;
|
||||
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
const params = payload.params;
|
||||
if (!params || typeof params !== "object") continue;
|
||||
|
||||
const text = (params as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim().length > 0) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("sandbox-agent commit message response was empty");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue