Stabilize SDK mode integration test

This commit is contained in:
Nathan Flurry 2026-03-10 22:37:27 -07:00
parent 24e99ac5e7
commit ec8b6afea9
274 changed files with 5412 additions and 7893 deletions

View file

@ -52,9 +52,7 @@ export class DaytonaClient {
image: options.image,
envVars: options.envVars,
labels: options.labels,
...(options.autoStopInterval !== undefined
? { autoStopInterval: options.autoStopInterval }
: {}),
...(options.autoStopInterval !== undefined ? { autoStopInterval: options.autoStopInterval } : {}),
});
return {

View file

@ -32,18 +32,10 @@ function commandLabel(cmd: SpiceCommand): string {
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")
);
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 }> {
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,
@ -51,8 +43,8 @@ async function tryRun(
env: {
...process.env,
NO_COLOR: "1",
FORCE_COLOR: "0"
}
FORCE_COLOR: "0",
},
});
}
@ -140,14 +132,7 @@ export async function gitSpiceAvailable(repoPath: string): Promise<boolean> {
export async function gitSpiceListStack(repoPath: string): Promise<SpiceStackEntry[]> {
try {
const { stdout } = await runSpice(repoPath, [
"log",
"short",
"--all",
"--json",
"--no-cr-status",
"--no-prompt"
]);
const { stdout } = await runSpice(repoPath, ["log", "short", "--all", "--json", "--no-cr-status", "--no-prompt"]);
return parseLogJson(stdout);
} catch {
return [];
@ -160,9 +145,9 @@ export async function gitSpiceSyncRepo(repoPath: string): Promise<void> {
[
["repo", "sync", "--restack", "--no-prompt"],
["repo", "sync", "--restack"],
["repo", "sync"]
["repo", "sync"],
],
"git-spice repo sync failed"
"git-spice repo sync failed",
);
}
@ -171,9 +156,9 @@ export async function gitSpiceRestackRepo(repoPath: string): Promise<void> {
repoPath,
[
["repo", "restack", "--no-prompt"],
["repo", "restack"]
["repo", "restack"],
],
"git-spice repo restack failed"
"git-spice repo restack failed",
);
}
@ -184,9 +169,9 @@ export async function gitSpiceRestackSubtree(repoPath: string, branchName: strin
["upstack", "restack", "--branch", branchName, "--no-prompt"],
["upstack", "restack", "--branch", branchName],
["branch", "restack", "--branch", branchName, "--no-prompt"],
["branch", "restack", "--branch", branchName]
["branch", "restack", "--branch", branchName],
],
`git-spice restack subtree failed for ${branchName}`
`git-spice restack subtree failed for ${branchName}`,
);
}
@ -195,41 +180,33 @@ export async function gitSpiceRebaseBranch(repoPath: string, branchName: string)
repoPath,
[
["branch", "restack", "--branch", branchName, "--no-prompt"],
["branch", "restack", "--branch", branchName]
["branch", "restack", "--branch", branchName],
],
`git-spice branch restack failed for ${branchName}`
`git-spice branch restack failed for ${branchName}`,
);
}
export async function gitSpiceReparentBranch(
repoPath: string,
branchName: string,
parentBranch: string
): Promise<void> {
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]
["branch", "onto", "--branch", branchName, parentBranch],
],
`git-spice reparent failed for ${branchName} -> ${parentBranch}`
`git-spice reparent failed for ${branchName} -> ${parentBranch}`,
);
}
export async function gitSpiceTrackBranch(
repoPath: string,
branchName: string,
parentBranch: string
): Promise<void> {
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]
["branch", "track", branchName, "--base", parentBranch],
],
`git-spice track failed for ${branchName}`
`git-spice track failed for ${branchName}`,
);
}

View file

@ -11,12 +11,7 @@ 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;
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;
@ -33,19 +28,18 @@ function ensureAskpassScript(): string {
// 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");
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);
@ -141,12 +135,7 @@ export async function ensureCloned(remoteUrl: string, targetPath: string): Promi
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 { 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]) {
@ -169,17 +158,10 @@ export async function remoteDefaultBaseRef(repoPath: string): Promise<string> {
}
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() }
);
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()
@ -191,24 +173,12 @@ export async function listRemoteBranches(repoPath: string): Promise<BranchSnapsh
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,
);
.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() });
await execFileAsync("git", ["-C", repoPath, "show-ref", "--verify", `refs/remotes/origin/${branchName}`], { env: gitEnv() });
return true;
} catch {
return false;
@ -233,11 +203,10 @@ export async function diffStatForBranch(repoPath: string, branchName: string): P
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 { stdout } = await execFileAsync("git", ["-C", repoPath, "diff", "--shortstat", `${baseRef}...${headRef}`], {
maxBuffer: 1024 * 1024,
env: gitEnv(),
});
const trimmed = stdout.trim();
if (!trimmed) {
return "+0/-0";
@ -252,20 +221,13 @@ export async function diffStatForBranch(repoPath: string, branchName: string): P
}
}
export async function conflictsWithMain(
repoPath: string,
branchName: string
): Promise<boolean> {
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() }
);
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 {
@ -279,11 +241,7 @@ export async function conflictsWithMain(
export async function getOriginOwner(repoPath: string): Promise<string> {
try {
const { stdout } = await execFileAsync(
"git",
["-C", repoPath, "remote", "get-url", "origin"],
{ env: gitEnv() }
);
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)?$/);

View file

@ -36,9 +36,7 @@ interface GhPrListItem {
}>;
}
function parseCiStatus(
checks: GhPrListItem["statusCheckRollup"]
): string | null {
function parseCiStatus(checks: GhPrListItem["statusCheckRollup"]): string | null {
if (!checks || checks.length === 0) return null;
let total = 0;
@ -53,12 +51,7 @@ function parseCiStatus(
if (conclusion === "SUCCESS" || state === "SUCCESS") {
successes++;
} else if (
status === "IN_PROGRESS" ||
status === "QUEUED" ||
status === "PENDING" ||
state === "PENDING"
) {
} else if (status === "IN_PROGRESS" || status === "QUEUED" || status === "PENDING" || state === "PENDING") {
hasRunning = true;
}
}
@ -70,9 +63,7 @@ function parseCiStatus(
return `${successes}/${total}`;
}
function parseReviewStatus(
reviews: GhPrListItem["reviews"]
): { status: string | null; reviewer: string | null } {
function parseReviewStatus(reviews: GhPrListItem["reviews"]): { status: string | null; reviewer: string | null } {
if (!reviews || reviews.length === 0) {
return { status: null, reviewer: null };
}
@ -120,35 +111,21 @@ function snapshotFromGhItem(item: GhPrListItem): PullRequestSnapshot {
isDraft: item.isDraft ?? false,
ciStatus: parseCiStatus(item.statusCheckRollup),
reviewStatus,
reviewer
reviewer,
};
}
const PR_JSON_FIELDS =
"number,headRefName,state,title,url,author,isDraft,statusCheckRollup,reviews";
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 { 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;
const headRefName = item.headRefName.includes(":") ? (item.headRefName.split(":").pop() ?? item.headRefName) : item.headRefName;
return snapshotFromGhItem({ ...item, headRefName });
});
@ -157,22 +134,9 @@ export async function listPullRequests(repoPath: string): Promise<PullRequestSna
}
}
export async function getPrInfo(
repoPath: string,
branchName: string
): Promise<PullRequestSnapshot | null> {
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 { 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);
@ -181,12 +145,7 @@ export async function getPrInfo(
}
}
export async function createPr(
repoPath: string,
headBranch: string,
title: string,
body?: string
): Promise<{ number: number; url: string }> {
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);
@ -196,7 +155,7 @@ export async function createPr(
const { stdout } = await execFileAsync("gh", args, {
maxBuffer: 1024 * 1024,
cwd: repoPath
cwd: repoPath,
});
// gh pr create outputs the PR URL on success
@ -208,29 +167,17 @@ export async function createPr(
return { number, url };
}
export async function getAllowedMergeMethod(
repoPath: string
): Promise<"squash" | "rebase" | "merge"> {
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 { 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 { 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";
@ -248,23 +195,12 @@ export async function getAllowedMergeMethod(
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 }
);
await execFileAsync("gh", ["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"], { cwd: repoPath });
}
export async function isPrMerged(
repoPath: string,
branchName: string
): Promise<boolean> {
export async function isPrMerged(repoPath: string, branchName: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync(
"gh",
["pr", "view", branchName, "--json", "state"],
{ cwd: repoPath }
);
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 {
@ -272,16 +208,9 @@ export async function isPrMerged(
}
}
export async function getPrTitle(
repoPath: string,
branchName: string
): Promise<string | null> {
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 { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", "title"], { cwd: repoPath });
const parsed = JSON.parse(stdout) as { title: string };
return parsed.title;
} catch {

View file

@ -21,17 +21,11 @@ export async function graphiteGet(repoPath: string, branchName: string): Promise
}
}
export async function graphiteCreateBranch(
repoPath: string,
branchName: string
): Promise<void> {
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> {
export async function graphiteCheckout(repoPath: string, branchName: string): Promise<void> {
await execFileAsync("gt", ["checkout", branchName], { cwd: repoPath });
}
@ -39,17 +33,11 @@ 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> {
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> {
export async function graphiteAbandon(repoPath: string, branchName: string): Promise<void> {
await execFileAsync("gt", ["abandon", branchName], { cwd: repoPath });
}
@ -58,14 +46,12 @@ export interface GraphiteStackEntry {
parentBranch: string | null;
}
export async function graphiteGetStack(
repoPath: string
): Promise<GraphiteStackEntry[]> {
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
maxBuffer: 1024 * 1024,
});
const parsed = JSON.parse(stdout) as Array<{
@ -77,14 +63,14 @@ export async function graphiteGetStack(
return parsed.map((entry) => ({
branchName: entry.branch ?? entry.name ?? "",
parentBranch: entry.parent ?? entry.parentBranch ?? null
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
maxBuffer: 1024 * 1024,
});
const entries: GraphiteStackEntry[] = [];
@ -113,9 +99,7 @@ export async function graphiteGetStack(
branchStack.pop();
}
const parentBranch = branchStack.length > 0
? branchStack[branchStack.length - 1] ?? null
: null;
const parentBranch = branchStack.length > 0 ? (branchStack[branchStack.length - 1] ?? null) : null;
entries.push({ branchName, parentBranch });
branchStack.push(branchName);
@ -128,15 +112,12 @@ export async function graphiteGetStack(
}
}
export async function graphiteGetParent(
repoPath: string,
branchName: string
): Promise<string | null> {
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
maxBuffer: 1024 * 1024,
});
// Parse output for parent branch reference

View file

@ -1,12 +1,5 @@
import type { AgentType } from "@openhandoff/shared";
import type {
ListEventsRequest,
ListPage,
ListPageRequest,
SessionEvent,
SessionPersistDriver,
SessionRecord
} from "sandbox-agent";
import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
import { SandboxAgent } from "sandbox-agent";
export type AgentId = AgentType | "opencode";
@ -118,18 +111,11 @@ export class SandboxAgentClient {
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")
);
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 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
@ -343,18 +329,14 @@ export class SandboxAgentClient {
} while (cursor);
}
async generateCommitMessage(
dir: string,
spec: string,
task: string
): Promise<string> {
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}`
`Spec/diff:\n${spec}`,
].join("\n");
const sdk = await this.sdk();