mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 07:04:48 +00:00
Improve Foundry auth and task flows
This commit is contained in:
parent
d75e8c31d1
commit
63b62e180d
26 changed files with 621 additions and 137 deletions
|
|
@ -164,7 +164,11 @@ For all Rivet/RivetKit implementation:
|
|||
- Integration tests use `setupTest()` from `rivetkit/test` and are gated behind `HF_ENABLE_ACTOR_INTEGRATION_TESTS=1`.
|
||||
- End-to-end testing must run against the dev backend started via `docker compose -f compose.dev.yaml up` (host -> container). Do not run E2E against an in-process test runtime.
|
||||
- E2E tests should talk to the backend over HTTP (default `http://127.0.0.1:7741/api/rivet`) and use real GitHub repos/PRs.
|
||||
- For Foundry live verification, use `rivet-dev/sandbox-agent-testing` as the default testing repo unless the task explicitly says otherwise.
|
||||
- Secrets (e.g. `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`) must be provided via environment variables, never hardcoded in the repo.
|
||||
- `~/misc/env.txt` and `~/misc/the-foundry.env` contain the expected local OpenAI + GitHub OAuth/App config for dev.
|
||||
- Do not assume `gh auth token` is sufficient for Foundry task provisioning against private repos. Sandbox/bootstrap git clone, push, and PR flows require a repo-capable `GITHUB_TOKEN`/`GH_TOKEN` in the backend container.
|
||||
- Preferred product behavior for org workspaces is to mint a GitHub App installation token from the workspace installation and inject it into backend/sandbox git operations. Do not rely on an operator's ambient CLI auth as the long-term solution.
|
||||
- Treat client E2E tests in `packages/client/test` as the primary end-to-end source of truth for product behavior.
|
||||
- Keep backend tests small and targeted. Only retain backend-only tests for invariants or persistence rules that are not well-covered through client E2E.
|
||||
- Do not keep large browser E2E suites around in a broken state. If a frontend browser E2E is not maintained and producing signal, remove it until it can be replaced with a reliable test.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,21 @@ services:
|
|||
# Support either GITHUB_TOKEN or GITHUB_PAT in local env files.
|
||||
GITHUB_TOKEN: "${GITHUB_TOKEN:-${GITHUB_PAT:-}}"
|
||||
GH_TOKEN: "${GH_TOKEN:-${GITHUB_TOKEN:-${GITHUB_PAT:-}}}"
|
||||
APP_URL: "${APP_URL:-}"
|
||||
BETTER_AUTH_URL: "${BETTER_AUTH_URL:-}"
|
||||
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:-}"
|
||||
GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID:-}"
|
||||
GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET:-}"
|
||||
GITHUB_REDIRECT_URI: "${GITHUB_REDIRECT_URI:-}"
|
||||
GITHUB_APP_ID: "${GITHUB_APP_ID:-}"
|
||||
GITHUB_APP_CLIENT_ID: "${GITHUB_APP_CLIENT_ID:-}"
|
||||
GITHUB_APP_CLIENT_SECRET: "${GITHUB_APP_CLIENT_SECRET:-}"
|
||||
GITHUB_APP_PRIVATE_KEY: "${GITHUB_APP_PRIVATE_KEY:-}"
|
||||
GITHUB_WEBHOOK_SECRET: "${GITHUB_WEBHOOK_SECRET:-${GITHUB_APP_WEBHOOK_SECRET:-}}"
|
||||
STRIPE_PUBLISHABLE_KEY: "${STRIPE_PUBLISHABLE_KEY:-}"
|
||||
STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY:-}"
|
||||
STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}"
|
||||
STRIPE_PRICE_TEAM: "${STRIPE_PRICE_TEAM:-}"
|
||||
DAYTONA_ENDPOINT: "${DAYTONA_ENDPOINT:-}"
|
||||
DAYTONA_API_KEY: "${DAYTONA_API_KEY:-}"
|
||||
HF_DAYTONA_ENDPOINT: "${HF_DAYTONA_ENDPOINT:-}"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { getActorRuntimeContext } from "../context.js";
|
|||
import { getProject, selfProjectPrSync } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
|
||||
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
|
||||
export interface ProjectPrSyncInput {
|
||||
workspaceId: string;
|
||||
|
|
@ -31,7 +32,8 @@ const CONTROL = {
|
|||
|
||||
async function pollPrs(c: { state: ProjectPrSyncState }): Promise<void> {
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const items = await driver.github.listPullRequests(c.state.repoPath);
|
||||
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
||||
const items = await driver.github.listPullRequests(c.state.repoPath, { githubToken: auth?.githubToken ?? null });
|
||||
const parent = getProject(c, c.state.workspaceId, c.state.repoId);
|
||||
await parent.applyPrSyncResult({ items, at: Date.now() });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getActorRuntimeContext } from "../context.js";
|
|||
import { getTask, getOrCreateTask, getOrCreateHistory, getOrCreateProjectBranchSync, getOrCreateProjectPrSync, selfProject } from "../handles.js";
|
||||
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { foundryRepoClonePath } from "../../services/foundry-paths.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { withRepoGitLock } from "../../services/repo-git-lock.js";
|
||||
import { branches, taskIndex, prCache, repoMeta } from "./db/schema.js";
|
||||
|
|
@ -112,7 +113,8 @@ export function projectWorkflowQueueName(name: ProjectQueueName): ProjectQueueNa
|
|||
async function ensureLocalClone(c: any, remoteUrl: string): Promise<string> {
|
||||
const { config, driver } = getActorRuntimeContext();
|
||||
const localPath = foundryRepoClonePath(config, c.state.workspaceId, c.state.repoId);
|
||||
await driver.git.ensureCloned(remoteUrl, localPath);
|
||||
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
||||
await driver.git.ensureCloned(remoteUrl, localPath, { githubToken: auth?.githubToken ?? null });
|
||||
c.state.localPath = localPath;
|
||||
return localPath;
|
||||
}
|
||||
|
|
@ -301,6 +303,26 @@ async function enrichTaskRecord(c: any, record: TaskRecord): Promise<TaskRecord>
|
|||
};
|
||||
}
|
||||
|
||||
async function reinsertTaskIndexRow(c: any, taskId: string, branchName: string | null, updatedAt: number): Promise<void> {
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(taskIndex)
|
||||
.values({
|
||||
taskId,
|
||||
branchName,
|
||||
createdAt: updatedAt || now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskIndex.taskId,
|
||||
set: {
|
||||
branchName,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
async function ensureProjectMutation(c: any, cmd: EnsureProjectCommand): Promise<EnsureProjectResult> {
|
||||
c.state.remoteUrl = cmd.remoteUrl;
|
||||
const localPath = await ensureLocalClone(c, cmd.remoteUrl);
|
||||
|
|
@ -454,9 +476,10 @@ async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand
|
|||
let headSha = "";
|
||||
let trackedInStack = false;
|
||||
let parentBranch: string | null = null;
|
||||
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
||||
|
||||
await withRepoGitLock(localPath, async () => {
|
||||
await driver.git.fetch(localPath);
|
||||
await driver.git.fetch(localPath, { githubToken: auth?.githubToken ?? null });
|
||||
const baseRef = await driver.git.remoteDefaultBaseRef(localPath);
|
||||
const normalizedBase = normalizeBaseBranchName(baseRef);
|
||||
|
||||
|
|
@ -467,8 +490,8 @@ async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand
|
|||
throw new Error(`Remote branch not found: ${branchName}`);
|
||||
}
|
||||
} else {
|
||||
await driver.git.ensureRemoteBranch(localPath, branchName);
|
||||
await driver.git.fetch(localPath);
|
||||
await driver.git.ensureRemoteBranch(localPath, branchName, { githubToken: auth?.githubToken ?? null });
|
||||
await driver.git.fetch(localPath, { githubToken: auth?.githubToken ?? null });
|
||||
try {
|
||||
headSha = await driver.git.revParse(localPath, `origin/${branchName}`);
|
||||
} catch {
|
||||
|
|
@ -947,7 +970,17 @@ export const projectActions = {
|
|||
|
||||
const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.taskId, cmd.taskId)).get();
|
||||
if (!row) {
|
||||
throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`);
|
||||
try {
|
||||
const h = getTask(c, c.state.workspaceId, c.state.repoId, cmd.taskId);
|
||||
const record = await h.get();
|
||||
await reinsertTaskIndexRow(c, cmd.taskId, record.branchName ?? null, record.updatedAt ?? Date.now());
|
||||
return await enrichTaskRecord(c, record);
|
||||
} catch (error) {
|
||||
if (isStaleTaskReferenceError(error)) {
|
||||
throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -142,10 +142,14 @@ export const task = actor({
|
|||
|
||||
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
|
||||
const result = await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30 * 60_000,
|
||||
});
|
||||
const response = expectQueueResponse<{ ok: boolean; error?: string }>(result);
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error ?? "task provisioning failed");
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
import { basename } from "node:path";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance } from "../handles.js";
|
||||
import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance, selfTask } from "../handles.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js";
|
||||
import { getCurrentRecord } from "./workflow/common.js";
|
||||
|
||||
|
|
@ -547,7 +548,26 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
|
|||
}
|
||||
|
||||
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
let record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId();
|
||||
await selfTask(c).provision({ providerId });
|
||||
record = await ensureWorkbenchSeeded(c);
|
||||
}
|
||||
|
||||
if (record.activeSessionId) {
|
||||
const existingSessions = await listSessionMetaRows(c);
|
||||
if (existingSessions.length === 0) {
|
||||
await ensureSessionMeta(c, {
|
||||
sessionId: record.activeSessionId,
|
||||
model: model ?? defaultModelForAgent(record.agentType),
|
||||
sessionName: "Session 1",
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
return { tabId: record.activeSessionId };
|
||||
}
|
||||
}
|
||||
|
||||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot create session without an active sandbox");
|
||||
}
|
||||
|
|
@ -783,7 +803,10 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
|
|||
throw new Error("cannot publish PR without a branch");
|
||||
}
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const created = await driver.github.createPr(c.state.repoLocalPath, record.branchName, record.title ?? c.state.task);
|
||||
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
||||
const created = await driver.github.createPr(c.state.repoLocalPath, record.branchName, record.title ?? c.state.task, undefined, {
|
||||
githubToken: auth?.githubToken ?? null,
|
||||
});
|
||||
await c.db
|
||||
.update(taskTable)
|
||||
.set({
|
||||
|
|
|
|||
|
|
@ -104,7 +104,10 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
|||
await msg.complete({ ok: true });
|
||||
} catch (error) {
|
||||
await loopCtx.step("init-failed-v2", async () => initFailedActivity(loopCtx, error));
|
||||
await msg.complete({ ok: false });
|
||||
await msg.complete({
|
||||
ok: false,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// @ts-nocheck
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { resolveCreateFlowDecision } from "../../../services/create-flow.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../../services/github-auth.js";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { getOrCreateTaskStatusSync, getOrCreateHistory, getOrCreateProject, getOrCreateSandboxInstance, getSandboxInstance, selfTask } from "../../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
|
|
@ -150,8 +151,9 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
|||
}
|
||||
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId);
|
||||
try {
|
||||
await driver.git.fetch(loopCtx.state.repoLocalPath);
|
||||
await driver.git.fetch(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null });
|
||||
} catch (error) {
|
||||
logActorWarning("task.init", "fetch before naming failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
|
|
@ -160,7 +162,9 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
|||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath)).map((branch: any) => branch.branchName);
|
||||
const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null })).map(
|
||||
(branch: any) => branch.branchName,
|
||||
);
|
||||
|
||||
const project = await getOrCreateProject(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.repoRemote);
|
||||
const reservedBranches = await project.listReservedBranches({});
|
||||
|
|
@ -274,6 +278,7 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
|
|||
});
|
||||
|
||||
try {
|
||||
const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId);
|
||||
const sandbox = await withActivityTimeout(timeoutMs, "createSandbox", async () =>
|
||||
provider.createSandbox({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
|
|
@ -281,6 +286,7 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
|
|||
repoRemote: loopCtx.state.repoRemote,
|
||||
branchName: loopCtx.state.branchName,
|
||||
taskId: loopCtx.state.taskId,
|
||||
githubToken: auth?.githubToken ?? null,
|
||||
debug: (message, context) => debugInit(loopCtx, message, context),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../../services/github-auth.js";
|
||||
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, resolveErrorDetail } from "./common.js";
|
||||
import { pushActiveBranchActivity } from "./push.js";
|
||||
|
|
@ -77,8 +78,10 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
|||
|
||||
if (self && self.prSubmitted) return;
|
||||
|
||||
const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId);
|
||||
|
||||
try {
|
||||
await driver.git.fetch(loopCtx.state.repoLocalPath);
|
||||
await driver.git.fetch(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null });
|
||||
} catch (error) {
|
||||
logActorWarning("task.status-sync", "fetch before PR submit failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
|
|
@ -98,7 +101,9 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
|||
historyKind: "task.push.auto",
|
||||
});
|
||||
|
||||
const pr = await driver.github.createPr(loopCtx.state.repoLocalPath, loopCtx.state.branchName, loopCtx.state.title);
|
||||
const pr = await driver.github.createPr(loopCtx.state.repoLocalPath, loopCtx.state.branchName, loopCtx.state.title, undefined, {
|
||||
githubToken: auth?.githubToken ?? null,
|
||||
});
|
||||
|
||||
await db.update(taskTable).set({ prSubmitted: 1, updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run();
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { getActorRuntimeContext } from "../context.js";
|
|||
import { getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { taskLookup, repos, providerProfiles } from "./db/schema.js";
|
||||
import { agentTypeForModel } from "../task/workbench.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
|
|
@ -213,7 +214,8 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord>
|
|||
}
|
||||
|
||||
const { driver } = getActorRuntimeContext();
|
||||
await driver.git.validateRemote(remoteUrl);
|
||||
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
||||
await driver.git.validateRemote(remoteUrl, { githubToken: auth?.githubToken ?? null });
|
||||
|
||||
const repoId = repoIdFromRemote(remoteUrl);
|
||||
const now = Date.now();
|
||||
|
|
@ -439,7 +441,7 @@ export const workspaceActions = {
|
|||
c.broadcast("workbenchUpdated", { at: Date.now() });
|
||||
},
|
||||
|
||||
async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string }> {
|
||||
async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string; tabId?: string }> {
|
||||
const created = await workspaceActions.createTask(c, {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: input.repoId,
|
||||
|
|
@ -448,7 +450,12 @@ export const workspaceActions = {
|
|||
...(input.branch ? { explicitBranchName: input.branch } : {}),
|
||||
...(input.model ? { agentType: agentTypeForModel(input.model) } : {}),
|
||||
});
|
||||
return { taskId: created.taskId };
|
||||
const task = await requireWorkbenchTask(c, created.taskId);
|
||||
const snapshot = await task.getWorkbench({});
|
||||
return {
|
||||
taskId: created.taskId,
|
||||
tabId: snapshot.tabs[0]?.id,
|
||||
};
|
||||
},
|
||||
|
||||
async markWorkbenchUnread(c: any, input: TaskWorkbenchSelectInput): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ function splitScopes(value: string): string[] {
|
|||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
function hasRepoScope(scopes: string[]): boolean {
|
||||
return scopes.some((scope) => scope === "repo" || scope.startsWith("repo:"));
|
||||
}
|
||||
|
||||
function parseEligibleOrganizationIds(value: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
|
|
@ -568,6 +572,32 @@ export const workspaceAppActions = {
|
|||
return await buildAppSnapshot(c, input.sessionId);
|
||||
},
|
||||
|
||||
async resolveAppGithubToken(
|
||||
c: any,
|
||||
input: { organizationId: string; requireRepoScope?: boolean },
|
||||
): Promise<{ accessToken: string; scopes: string[] } | null> {
|
||||
assertAppWorkspace(c);
|
||||
const rows = await c.db.select().from(appSessions).orderBy(desc(appSessions.updatedAt)).all();
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.activeOrganizationId !== input.organizationId || !row.githubAccessToken) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scopes = splitScopes(row.githubScope);
|
||||
if (input.requireRepoScope !== false && !hasRepoScope(scopes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: row.githubAccessToken,
|
||||
scopes,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
async startAppGithubAuth(c: any, input: { sessionId: string }): Promise<{ url: string }> {
|
||||
assertAppWorkspace(c);
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
|
|
@ -702,18 +732,34 @@ export const workspaceAppActions = {
|
|||
});
|
||||
|
||||
try {
|
||||
const repositories =
|
||||
organization.snapshot.kind === "personal"
|
||||
? await appShell.github.listUserRepositories(session.githubAccessToken)
|
||||
: organization.githubInstallationId
|
||||
? await appShell.github.listInstallationRepositories(organization.githubInstallationId)
|
||||
: (() => {
|
||||
throw new GitHubAppError("GitHub App installation required before importing repositories", 400);
|
||||
})();
|
||||
let repositories;
|
||||
let installationStatus = organization.snapshot.github.installationStatus;
|
||||
|
||||
if (organization.snapshot.kind === "personal") {
|
||||
repositories = await appShell.github.listUserRepositories(session.githubAccessToken);
|
||||
installationStatus = "connected";
|
||||
} else if (organization.githubInstallationId) {
|
||||
try {
|
||||
repositories = await appShell.github.listInstallationRepositories(organization.githubInstallationId);
|
||||
} catch (error) {
|
||||
if (!(error instanceof GitHubAppError) || (error.status !== 403 && error.status !== 404)) {
|
||||
throw error;
|
||||
}
|
||||
repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) =>
|
||||
repository.fullName.startsWith(`${organization.githubLogin}/`),
|
||||
);
|
||||
installationStatus = "reconnect_required";
|
||||
}
|
||||
} else {
|
||||
repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) =>
|
||||
repository.fullName.startsWith(`${organization.githubLogin}/`),
|
||||
);
|
||||
installationStatus = "reconnect_required";
|
||||
}
|
||||
|
||||
await workspace.applyOrganizationSyncCompleted({
|
||||
repositories,
|
||||
installationStatus: organization.snapshot.kind === "personal" ? "connected" : organization.snapshot.github.installationStatus,
|
||||
installationStatus,
|
||||
lastSyncLabel: repositories.length > 0 ? "Synced just now" : "No repositories available",
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -181,9 +181,7 @@ SET \`github_sync_status\` = CASE
|
|||
ELSE 'pending'
|
||||
END;
|
||||
`,
|
||||
m0010: `ALTER TABLE \`app_sessions\` ADD COLUMN \`starter_repo_status\` text NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE \`app_sessions\` ADD COLUMN \`starter_repo_starred_at\` integer;
|
||||
ALTER TABLE \`app_sessions\` ADD COLUMN \`starter_repo_skipped_at\` integer;
|
||||
m0010: `-- no-op: starter_repo_* columns are already present in m0007 app_sessions
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,13 +40,13 @@ import { SandboxAgentClient } from "./integrations/sandbox-agent/client.js";
|
|||
import { DaytonaClient } from "./integrations/daytona/client.js";
|
||||
|
||||
export interface GitDriver {
|
||||
validateRemote(remoteUrl: string): Promise<void>;
|
||||
ensureCloned(remoteUrl: string, targetPath: string): Promise<void>;
|
||||
fetch(repoPath: string): Promise<void>;
|
||||
listRemoteBranches(repoPath: string): Promise<BranchSnapshot[]>;
|
||||
validateRemote(remoteUrl: string, options?: { githubToken?: string | null }): Promise<void>;
|
||||
ensureCloned(remoteUrl: string, targetPath: string, options?: { githubToken?: string | null }): Promise<void>;
|
||||
fetch(repoPath: string, options?: { githubToken?: string | null }): Promise<void>;
|
||||
listRemoteBranches(repoPath: string, options?: { githubToken?: string | null }): Promise<BranchSnapshot[]>;
|
||||
remoteDefaultBaseRef(repoPath: string): Promise<string>;
|
||||
revParse(repoPath: string, ref: string): Promise<string>;
|
||||
ensureRemoteBranch(repoPath: string, branchName: string): Promise<void>;
|
||||
ensureRemoteBranch(repoPath: string, branchName: string, options?: { githubToken?: string | null }): Promise<void>;
|
||||
diffStatForBranch(repoPath: string, branchName: string): Promise<string>;
|
||||
conflictsWithMain(repoPath: string, branchName: string): Promise<boolean>;
|
||||
}
|
||||
|
|
@ -68,9 +68,15 @@ export interface StackDriver {
|
|||
}
|
||||
|
||||
export interface GithubDriver {
|
||||
listPullRequests(repoPath: string): Promise<PullRequestSnapshot[]>;
|
||||
createPr(repoPath: string, headBranch: string, title: string, body?: string): Promise<{ number: number; url: string }>;
|
||||
starRepository(repoFullName: string): Promise<void>;
|
||||
listPullRequests(repoPath: string, options?: { githubToken?: string | null }): Promise<PullRequestSnapshot[]>;
|
||||
createPr(
|
||||
repoPath: string,
|
||||
headBranch: string,
|
||||
title: string,
|
||||
body?: string,
|
||||
options?: { githubToken?: string | null },
|
||||
): Promise<{ number: number; url: string }>;
|
||||
starRepository(repoFullName: string, options?: { githubToken?: string | null }): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SandboxAgentClientLike {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,28 @@ export interface BackendStartOptions {
|
|||
port?: number;
|
||||
}
|
||||
|
||||
function isRetryableAppActorError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes("Actor not ready") || message.includes("socket connection was closed unexpectedly");
|
||||
}
|
||||
|
||||
async function withRetries<T>(run: () => Promise<T>, attempts = 20, delayMs = 250): Promise<T> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isRetryableAppActorError(error) || attempt === attempts) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
||||
}
|
||||
|
||||
export async function startBackend(options: BackendStartOptions = {}): Promise<void> {
|
||||
// sandbox-agent agent plugins vary on which env var they read for OpenAI/Codex auth.
|
||||
// Normalize to keep local dev + docker-compose simple.
|
||||
|
|
@ -48,9 +70,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
initActorRuntimeContext(config, providers, notifications, driver, createDefaultAppShellServices());
|
||||
|
||||
registry.startRunner();
|
||||
const inner = registry.serve();
|
||||
const managerOrigin = `http://127.0.0.1:${resolveManagerPort()}`;
|
||||
const actorClient = createClient({
|
||||
endpoint: `http://127.0.0.1:${resolveManagerPort()}`,
|
||||
endpoint: managerOrigin,
|
||||
disableMetadataLookup: true,
|
||||
}) as any;
|
||||
|
||||
|
|
@ -98,8 +120,12 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
);
|
||||
const forward = async (c: any) => {
|
||||
try {
|
||||
// RivetKit serverless handler is configured with basePath `/api/rivet` by default.
|
||||
return await inner.fetch(c.req.raw);
|
||||
// Proxy /api/rivet traffic to the long-lived RivetKit manager rather than
|
||||
// invoking RivetKit's serverless entrypoints in-process.
|
||||
const requestUrl = new URL(c.req.url);
|
||||
const managerPath = requestUrl.pathname.replace(/^\/api\/rivet(?=\/|$)/, "") || "/";
|
||||
const targetUrl = new URL(`${managerPath}${requestUrl.search}`, managerOrigin);
|
||||
return await fetch(new Request(targetUrl, c.req.raw));
|
||||
} catch (err) {
|
||||
if (err instanceof URIError) {
|
||||
return c.text("Bad Request: Malformed URI", 400);
|
||||
|
|
@ -109,27 +135,32 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
};
|
||||
|
||||
const appWorkspace = async () =>
|
||||
await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
|
||||
createWithInput: APP_SHELL_WORKSPACE_ID,
|
||||
});
|
||||
await withRetries(
|
||||
async () =>
|
||||
await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
|
||||
createWithInput: APP_SHELL_WORKSPACE_ID,
|
||||
}),
|
||||
);
|
||||
|
||||
const appWorkspaceAction = async <T>(run: (workspace: any) => Promise<T>): Promise<T> => await withRetries(async () => await run(await appWorkspace()));
|
||||
|
||||
const resolveSessionId = async (c: any): Promise<string> => {
|
||||
const requested = c.req.header("x-foundry-session");
|
||||
const { sessionId } = await (await appWorkspace()).ensureAppSession({
|
||||
requestedSessionId: requested ?? null,
|
||||
});
|
||||
const { sessionId } = await appWorkspaceAction(
|
||||
async (workspace) => await workspace.ensureAppSession(requested && requested.trim().length > 0 ? { requestedSessionId: requested } : {}),
|
||||
);
|
||||
c.header("x-foundry-session", sessionId);
|
||||
return sessionId;
|
||||
};
|
||||
|
||||
app.get("/api/rivet/app/snapshot", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(await (await appWorkspace()).getAppSnapshot({ sessionId }));
|
||||
return c.json(await appWorkspaceAction(async (workspace) => await workspace.getAppSnapshot({ sessionId })));
|
||||
});
|
||||
|
||||
app.get("/api/rivet/app/auth/github/start", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
const result = await (await appWorkspace()).startAppGithubAuth({ sessionId });
|
||||
const result = await appWorkspaceAction(async (workspace) => await workspace.startAppGithubAuth({ sessionId }));
|
||||
return Response.redirect(result.url, 302);
|
||||
});
|
||||
|
||||
|
|
@ -139,38 +170,44 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
if (!code || !state) {
|
||||
return c.text("Missing GitHub OAuth callback parameters", 400);
|
||||
}
|
||||
const result = await (await appWorkspace()).completeAppGithubAuth({ code, state });
|
||||
const result = await appWorkspaceAction(async (workspace) => await workspace.completeAppGithubAuth({ code, state }));
|
||||
c.header("x-foundry-session", result.sessionId);
|
||||
return Response.redirect(result.redirectTo, 302);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/sign-out", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(await (await appWorkspace()).signOutApp({ sessionId }));
|
||||
return c.json(await appWorkspaceAction(async (workspace) => await workspace.signOutApp({ sessionId })));
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/onboarding/starter-repo/skip", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(await (await appWorkspace()).skipAppStarterRepo({ sessionId }));
|
||||
return c.json(await appWorkspaceAction(async (workspace) => await workspace.skipAppStarterRepo({ sessionId })));
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/starter-repo/star", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).starAppStarterRepo({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
await appWorkspaceAction(
|
||||
async (workspace) =>
|
||||
await workspace.starAppStarterRepo({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/select", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).selectAppOrganization({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
await appWorkspaceAction(
|
||||
async (workspace) =>
|
||||
await workspace.selectAppOrganization({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -178,33 +215,42 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
const sessionId = await resolveSessionId(c);
|
||||
const body = await c.req.json();
|
||||
return c.json(
|
||||
await (await appWorkspace()).updateAppOrganizationProfile({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
displayName: typeof body?.displayName === "string" ? body.displayName : "",
|
||||
slug: typeof body?.slug === "string" ? body.slug : "",
|
||||
primaryDomain: typeof body?.primaryDomain === "string" ? body.primaryDomain : "",
|
||||
}),
|
||||
await appWorkspaceAction(
|
||||
async (workspace) =>
|
||||
await workspace.updateAppOrganizationProfile({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
displayName: typeof body?.displayName === "string" ? body.displayName : "",
|
||||
slug: typeof body?.slug === "string" ? body.slug : "",
|
||||
primaryDomain: typeof body?.primaryDomain === "string" ? body.primaryDomain : "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/import", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).triggerAppRepoImport({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
await appWorkspaceAction(
|
||||
async (workspace) =>
|
||||
await workspace.triggerAppRepoImport({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/reconnect", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).beginAppGithubInstall({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
await appWorkspaceAction(
|
||||
async (workspace) =>
|
||||
await workspace.beginAppGithubInstall({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,12 @@ 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;
|
||||
interface GitAuthOptions {
|
||||
githubToken?: string | null;
|
||||
}
|
||||
|
||||
function resolveGithubToken(options?: GitAuthOptions): string | null {
|
||||
const token = options?.githubToken ?? 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;
|
||||
|
|
@ -47,30 +51,45 @@ function ensureAskpassScript(): string {
|
|||
return path;
|
||||
}
|
||||
|
||||
function gitEnv(): Record<string, string> {
|
||||
function gitEnv(options?: GitAuthOptions): Record<string, string> {
|
||||
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
|
||||
env.GIT_TERMINAL_PROMPT = "0";
|
||||
|
||||
const token = resolveGithubToken();
|
||||
const token = resolveGithubToken(options);
|
||||
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;
|
||||
env.GITHUB_TOKEN = token;
|
||||
env.GH_TOKEN = token;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
async function configureGithubAuth(repoPath: string, options?: GitAuthOptions): Promise<void> {
|
||||
const token = resolveGithubToken(options);
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = Buffer.from(`x-access-token:${token}`, "utf8").toString("base64");
|
||||
await execFileAsync("git", ["-C", repoPath, "config", "--local", "credential.helper", ""], {
|
||||
env: gitEnv(options),
|
||||
});
|
||||
await execFileAsync("git", ["-C", repoPath, "config", "--local", "http.https://github.com/.extraheader", `AUTHORIZATION: basic ${authHeader}`], {
|
||||
env: gitEnv(options),
|
||||
});
|
||||
}
|
||||
|
||||
export interface BranchSnapshot {
|
||||
branchName: string;
|
||||
commitSha: string;
|
||||
}
|
||||
|
||||
export async function fetch(repoPath: string): Promise<void> {
|
||||
export async function fetch(repoPath: string, options?: GitAuthOptions): Promise<void> {
|
||||
await execFileAsync("git", ["-C", repoPath, "fetch", "--prune"], {
|
||||
timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS,
|
||||
env: gitEnv(),
|
||||
env: gitEnv(options),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +98,7 @@ export async function revParse(repoPath: string, ref: string): Promise<string> {
|
|||
return stdout.trim();
|
||||
}
|
||||
|
||||
export async function validateRemote(remoteUrl: string): Promise<void> {
|
||||
export async function validateRemote(remoteUrl: string, options?: GitAuthOptions): Promise<void> {
|
||||
const remote = remoteUrl.trim();
|
||||
if (!remote) {
|
||||
throw new Error("remoteUrl is required");
|
||||
|
|
@ -91,7 +110,7 @@ export async function validateRemote(remoteUrl: string): Promise<void> {
|
|||
cwd: tmpdir(),
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS,
|
||||
env: gitEnv(),
|
||||
env: gitEnv(options),
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -103,7 +122,7 @@ function isGitRepo(path: string): boolean {
|
|||
return existsSync(resolve(path, ".git"));
|
||||
}
|
||||
|
||||
export async function ensureCloned(remoteUrl: string, targetPath: string): Promise<void> {
|
||||
export async function ensureCloned(remoteUrl: string, targetPath: string, options?: GitAuthOptions): Promise<void> {
|
||||
const remote = remoteUrl.trim();
|
||||
if (!remote) {
|
||||
throw new Error("remoteUrl is required");
|
||||
|
|
@ -118,9 +137,10 @@ export async function ensureCloned(remoteUrl: string, targetPath: string): Promi
|
|||
await execFileAsync("git", ["-C", targetPath, "remote", "set-url", "origin", remote], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS,
|
||||
env: gitEnv(),
|
||||
env: gitEnv(options),
|
||||
});
|
||||
await fetch(targetPath);
|
||||
await configureGithubAuth(targetPath, options);
|
||||
await fetch(targetPath, options);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -128,9 +148,40 @@ export async function ensureCloned(remoteUrl: string, targetPath: string): Promi
|
|||
await execFileAsync("git", ["clone", remote, targetPath], {
|
||||
maxBuffer: 1024 * 1024 * 8,
|
||||
timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS,
|
||||
env: gitEnv(options),
|
||||
});
|
||||
await configureGithubAuth(targetPath, options);
|
||||
await fetch(targetPath, options);
|
||||
await ensureLocalBaseBranch(targetPath);
|
||||
}
|
||||
|
||||
async function hasLocalBranches(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("git", ["-C", repoPath, "for-each-ref", "--format=%(refname:short)", "refs/heads"], {
|
||||
env: gitEnv(),
|
||||
});
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.some(Boolean);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLocalBaseBranch(repoPath: string): Promise<void> {
|
||||
if (await hasLocalBranches(repoPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseRef = await remoteDefaultBaseRef(repoPath);
|
||||
const localBranch = baseRef.replace(/^origin\//, "");
|
||||
|
||||
await execFileAsync("git", ["-C", repoPath, "checkout", "-B", localBranch, baseRef], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS,
|
||||
env: gitEnv(),
|
||||
});
|
||||
await fetch(targetPath);
|
||||
}
|
||||
|
||||
export async function remoteDefaultBaseRef(repoPath: string): Promise<string> {
|
||||
|
|
@ -157,10 +208,11 @@ export async function remoteDefaultBaseRef(repoPath: string): Promise<string> {
|
|||
return "origin/main";
|
||||
}
|
||||
|
||||
export async function listRemoteBranches(repoPath: string): Promise<BranchSnapshot[]> {
|
||||
export async function listRemoteBranches(repoPath: string, options?: GitAuthOptions): Promise<BranchSnapshot[]> {
|
||||
await fetch(repoPath, options);
|
||||
const { stdout } = await execFileAsync("git", ["-C", repoPath, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/remotes/origin"], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: gitEnv(),
|
||||
env: gitEnv(options),
|
||||
});
|
||||
|
||||
return stdout
|
||||
|
|
@ -185,8 +237,9 @@ async function remoteBranchExists(repoPath: string, branchName: string): Promise
|
|||
}
|
||||
}
|
||||
|
||||
export async function ensureRemoteBranch(repoPath: string, branchName: string): Promise<void> {
|
||||
await fetch(repoPath);
|
||||
export async function ensureRemoteBranch(repoPath: string, branchName: string, options?: GitAuthOptions): Promise<void> {
|
||||
await fetch(repoPath, options);
|
||||
await ensureLocalBaseBranch(repoPath);
|
||||
if (await remoteBranchExists(repoPath, branchName)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -194,9 +247,9 @@ export async function ensureRemoteBranch(repoPath: string, branchName: string):
|
|||
const baseRef = await remoteDefaultBaseRef(repoPath);
|
||||
await execFileAsync("git", ["-C", repoPath, "push", "origin", `${baseRef}:refs/heads/${branchName}`], {
|
||||
maxBuffer: 1024 * 1024 * 2,
|
||||
env: gitEnv(),
|
||||
env: gitEnv(options),
|
||||
});
|
||||
await fetch(repoPath);
|
||||
await fetch(repoPath, options);
|
||||
}
|
||||
|
||||
export async function diffStatForBranch(repoPath: string, branchName: string): Promise<string> {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,20 @@ import { promisify } from "node:util";
|
|||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface GithubAuthOptions {
|
||||
githubToken?: string | null;
|
||||
}
|
||||
|
||||
function ghEnv(options?: GithubAuthOptions): Record<string, string> {
|
||||
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
|
||||
const token = options?.githubToken?.trim();
|
||||
if (token) {
|
||||
env.GH_TOKEN = token;
|
||||
env.GITHUB_TOKEN = token;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export interface PullRequestSnapshot {
|
||||
number: number;
|
||||
headRefName: string;
|
||||
|
|
@ -117,9 +131,13 @@ function snapshotFromGhItem(item: GhPrListItem): PullRequestSnapshot {
|
|||
|
||||
const PR_JSON_FIELDS = "number,headRefName,state,title,url,author,isDraft,statusCheckRollup,reviews";
|
||||
|
||||
export async function listPullRequests(repoPath: string): Promise<PullRequestSnapshot[]> {
|
||||
export async function listPullRequests(repoPath: string, options?: GithubAuthOptions): 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,
|
||||
env: ghEnv(options),
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(stdout) as GhPrListItem[];
|
||||
|
||||
|
|
@ -134,9 +152,13 @@ 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, options?: GithubAuthOptions): 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,
|
||||
env: ghEnv(options),
|
||||
});
|
||||
|
||||
const item = JSON.parse(stdout) as GhPrListItem;
|
||||
return snapshotFromGhItem(item);
|
||||
|
|
@ -145,7 +167,13 @@ export async function getPrInfo(repoPath: string, branchName: string): Promise<P
|
|||
}
|
||||
}
|
||||
|
||||
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,
|
||||
options?: GithubAuthOptions,
|
||||
): Promise<{ number: number; url: string }> {
|
||||
const args = ["pr", "create", "--title", title, "--head", headBranch];
|
||||
if (body) {
|
||||
args.push("--body", body);
|
||||
|
|
@ -156,6 +184,7 @@ export async function createPr(repoPath: string, headBranch: string, title: stri
|
|||
const { stdout } = await execFileAsync("gh", args, {
|
||||
maxBuffer: 1024 * 1024,
|
||||
cwd: repoPath,
|
||||
env: ghEnv(options),
|
||||
});
|
||||
|
||||
// gh pr create outputs the PR URL on success
|
||||
|
|
@ -167,10 +196,11 @@ export async function createPr(repoPath: string, headBranch: string, title: stri
|
|||
return { number, url };
|
||||
}
|
||||
|
||||
export async function starRepository(repoFullName: string): Promise<void> {
|
||||
export async function starRepository(repoFullName: string, options?: GithubAuthOptions): Promise<void> {
|
||||
try {
|
||||
await execFileAsync("gh", ["api", "--method", "PUT", `user/starred/${repoFullName}`], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: ghEnv(options),
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
|
|
@ -179,16 +209,17 @@ export async function starRepository(repoFullName: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getAllowedMergeMethod(repoPath: string): Promise<"squash" | "rebase" | "merge"> {
|
||||
export async function getAllowedMergeMethod(repoPath: string, options?: GithubAuthOptions): 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, env: ghEnv(options) });
|
||||
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,
|
||||
env: ghEnv(options),
|
||||
});
|
||||
|
||||
const lines = stdout.trim().split("\n");
|
||||
|
|
@ -205,14 +236,14 @@ export async function getAllowedMergeMethod(repoPath: string): Promise<"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 mergePr(repoPath: string, prNumber: number, options?: GithubAuthOptions): Promise<void> {
|
||||
const method = await getAllowedMergeMethod(repoPath, options);
|
||||
await execFileAsync("gh", ["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"], { cwd: repoPath, env: ghEnv(options) });
|
||||
}
|
||||
|
||||
export async function isPrMerged(repoPath: string, branchName: string): Promise<boolean> {
|
||||
export async function isPrMerged(repoPath: string, branchName: string, options?: GithubAuthOptions): 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, env: ghEnv(options) });
|
||||
const parsed = JSON.parse(stdout) as { state: string };
|
||||
return parsed.state.toUpperCase() === "MERGED";
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -145,6 +145,18 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
return envVars;
|
||||
}
|
||||
|
||||
private buildShellExports(extra: Record<string, string> = {}): string[] {
|
||||
const merged = {
|
||||
...this.buildEnvVars(),
|
||||
...extra,
|
||||
};
|
||||
|
||||
return Object.entries(merged).map(([key, value]) => {
|
||||
const encoded = Buffer.from(value, "utf8").toString("base64");
|
||||
return `export ${key}="$(printf %s ${JSON.stringify(encoded)} | base64 -d)"`;
|
||||
});
|
||||
}
|
||||
|
||||
private buildSnapshotImage() {
|
||||
// Use Daytona image build + snapshot caching so base tooling (git + sandbox-agent)
|
||||
// is prepared once and reused for subsequent sandboxes.
|
||||
|
|
@ -240,13 +252,18 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
"set -euo pipefail",
|
||||
"export GIT_TERMINAL_PROMPT=0",
|
||||
"export GIT_ASKPASS=/bin/echo",
|
||||
`TOKEN=${JSON.stringify(req.githubToken ?? "")}`,
|
||||
'if [ -z "$TOKEN" ]; then TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}"; fi',
|
||||
"GIT_AUTH_ARGS=()",
|
||||
`if [ -n "$TOKEN" ] && [[ "${req.repoRemote}" == https://github.com/* ]]; then AUTH_HEADER="$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\\n')"; GIT_AUTH_ARGS=(-c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_HEADER"); fi`,
|
||||
`rm -rf "${repoDir}"`,
|
||||
`mkdir -p "${repoDir}"`,
|
||||
`rmdir "${repoDir}"`,
|
||||
// Clone without embedding credentials. Auth for pushing is configured by the agent at runtime.
|
||||
`git clone "${req.repoRemote}" "${repoDir}"`,
|
||||
// Foundry test repos can be private, so clone/fetch must use the sandbox's GitHub token when available.
|
||||
`git "\${GIT_AUTH_ARGS[@]}" clone "${req.repoRemote}" "${repoDir}"`,
|
||||
`cd "${repoDir}"`,
|
||||
`git fetch origin --prune`,
|
||||
`if [ -n "$TOKEN" ] && [[ "${req.repoRemote}" == https://github.com/* ]]; then git config --local credential.helper ""; git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic $AUTH_HEADER"; fi`,
|
||||
`git "\${GIT_AUTH_ARGS[@]}" fetch origin --prune`,
|
||||
// The task branch may not exist remotely yet (agent push creates it). Base off current branch (default branch).
|
||||
`if git show-ref --verify --quiet "refs/remotes/origin/${req.branchName}"; then git checkout -B "${req.branchName}" "origin/${req.branchName}"; else git checkout -B "${req.branchName}" "$(git branch --show-current 2>/dev/null || echo main)"; fi`,
|
||||
`git config user.email "foundry@local" >/dev/null 2>&1 || true`,
|
||||
|
|
@ -337,6 +354,9 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
async ensureSandboxAgent(req: EnsureAgentRequest): Promise<AgentEndpoint> {
|
||||
const client = this.requireClient();
|
||||
const acpRequestTimeoutMs = this.getAcpRequestTimeoutMs();
|
||||
const sandboxAgentExports = this.buildShellExports({
|
||||
SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS: acpRequestTimeoutMs.toString(),
|
||||
});
|
||||
|
||||
await this.ensureStarted(req.sandboxId);
|
||||
|
||||
|
|
@ -387,7 +407,17 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`'set -euo pipefail; export PATH="$HOME/.local/bin:$PATH"; command -v sandbox-agent >/dev/null 2>&1; if pgrep -x sandbox-agent >/dev/null; then exit 0; fi; nohup env SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=${acpRequestTimeoutMs} sandbox-agent server --no-token --host 0.0.0.0 --port ${DaytonaProvider.SANDBOX_AGENT_PORT} >/tmp/sandbox-agent.log 2>&1 &'`,
|
||||
JSON.stringify(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
'export PATH="$HOME/.local/bin:$PATH"',
|
||||
...sandboxAgentExports,
|
||||
"command -v sandbox-agent >/dev/null 2>&1",
|
||||
"if pgrep -x sandbox-agent >/dev/null; then exit 0; fi",
|
||||
'rm -f "$HOME/.codex/auth.json" "$HOME/.config/codex/auth.json"',
|
||||
`nohup sandbox-agent server --no-token --host 0.0.0.0 --port ${DaytonaProvider.SANDBOX_AGENT_PORT} >/tmp/sandbox-agent.log 2>&1 &`,
|
||||
].join("; "),
|
||||
),
|
||||
].join(" "),
|
||||
"start sandbox-agent",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ export class LocalProvider implements SandboxProvider {
|
|||
const sandboxId = req.taskId || `local-${randomUUID()}`;
|
||||
const repoDir = this.repoDir(req.workspaceId, sandboxId);
|
||||
mkdirSync(dirname(repoDir), { recursive: true });
|
||||
await this.git.ensureCloned(req.repoRemote, repoDir);
|
||||
await this.git.ensureCloned(req.repoRemote, repoDir, { githubToken: req.githubToken });
|
||||
await checkoutBranch(repoDir, req.branchName, this.git);
|
||||
return this.sandboxHandle(req.workspaceId, sandboxId, repoDir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export interface CreateSandboxRequest {
|
|||
repoRemote: string;
|
||||
branchName: string;
|
||||
taskId: string;
|
||||
githubToken?: string | null;
|
||||
debug?: (message: string, context?: Record<string, unknown>) => void;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,14 @@ export interface GitHubAppClientOptions {
|
|||
webhookSecret?: string;
|
||||
}
|
||||
|
||||
function normalizePem(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.includes("\\n") ? value.replace(/\\n/g, "\n") : value;
|
||||
}
|
||||
|
||||
export class GitHubAppClient {
|
||||
private readonly apiBaseUrl: string;
|
||||
private readonly authBaseUrl: string;
|
||||
|
|
@ -90,7 +98,7 @@ export class GitHubAppClient {
|
|||
this.clientSecret = options.clientSecret ?? process.env.GITHUB_CLIENT_SECRET;
|
||||
this.redirectUri = options.redirectUri ?? process.env.GITHUB_REDIRECT_URI;
|
||||
this.appId = options.appId ?? process.env.GITHUB_APP_ID;
|
||||
this.appPrivateKey = options.appPrivateKey ?? process.env.GITHUB_APP_PRIVATE_KEY;
|
||||
this.appPrivateKey = normalizePem(options.appPrivateKey ?? process.env.GITHUB_APP_PRIVATE_KEY);
|
||||
this.webhookSecret = options.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET;
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +151,7 @@ export class GitHubAppClient {
|
|||
const url = new URL(`${this.authBaseUrl}/login/oauth/authorize`);
|
||||
url.searchParams.set("client_id", this.clientId);
|
||||
url.searchParams.set("redirect_uri", this.redirectUri);
|
||||
url.searchParams.set("scope", "read:user user:email read:org");
|
||||
url.searchParams.set("scope", "read:user user:email read:org repo");
|
||||
url.searchParams.set("state", state);
|
||||
return url.toString();
|
||||
}
|
||||
|
|
@ -273,7 +281,7 @@ export class GitHubAppClient {
|
|||
full_name: string;
|
||||
clone_url: string;
|
||||
private: boolean;
|
||||
}>("/user/repos?per_page=100&affiliation=owner&sort=updated", accessToken);
|
||||
}>("/user/repos?per_page=100&affiliation=owner,collaborator,organization_member&sort=updated", accessToken);
|
||||
|
||||
return repositories.map((repository) => ({
|
||||
fullName: repository.full_name,
|
||||
|
|
|
|||
30
foundry/packages/backend/src/services/github-auth.ts
Normal file
30
foundry/packages/backend/src/services/github-auth.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { getOrCreateWorkspace } from "../actors/handles.js";
|
||||
import { APP_SHELL_WORKSPACE_ID } from "../actors/workspace/app-shell.js";
|
||||
|
||||
export interface ResolvedGithubAuth {
|
||||
githubToken: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export async function resolveWorkspaceGithubAuth(c: any, workspaceId: string): Promise<ResolvedGithubAuth | null> {
|
||||
if (!workspaceId || workspaceId === APP_SHELL_WORKSPACE_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const appWorkspace = await getOrCreateWorkspace(c, APP_SHELL_WORKSPACE_ID);
|
||||
const resolved = await appWorkspace.resolveAppGithubToken({
|
||||
organizationId: workspaceId,
|
||||
requireRepoScope: true,
|
||||
});
|
||||
if (!resolved?.accessToken) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
githubToken: resolved.accessToken,
|
||||
scopes: Array.isArray(resolved.scopes) ? resolved.scopes : [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from "../components/mock-onboarding";
|
||||
import { defaultWorkspaceId, isMockFrontendClient } from "../lib/env";
|
||||
import { activeMockOrganization, getMockOrganizationById, isAppSnapshotBootstrapping, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
|
||||
import { taskWorkbenchClient } from "../lib/workbench";
|
||||
import { getTaskWorkbenchClient } from "../lib/workbench";
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootLayout,
|
||||
|
|
@ -304,6 +304,7 @@ function AppWorkspaceGate({ workspaceId, children }: { workspaceId: string; chil
|
|||
}
|
||||
|
||||
function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) {
|
||||
const taskWorkbenchClient = getTaskWorkbenchClient(workspaceId);
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import {
|
|||
type ModelId,
|
||||
} from "./mock-layout/view-model";
|
||||
import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app";
|
||||
import { taskWorkbenchClient } from "../lib/workbench";
|
||||
import { getTaskWorkbenchClient } from "../lib/workbench";
|
||||
|
||||
function firstAgentTabId(task: Task): string | null {
|
||||
return task.tabs[0]?.id ?? null;
|
||||
|
|
@ -61,6 +61,7 @@ function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openD
|
|||
}
|
||||
|
||||
const TranscriptPanel = memo(function TranscriptPanel({
|
||||
taskWorkbenchClient,
|
||||
task,
|
||||
activeTabId,
|
||||
lastAgentTabId,
|
||||
|
|
@ -70,6 +71,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onSetLastAgentTabId,
|
||||
onSetOpenDiffs,
|
||||
}: {
|
||||
taskWorkbenchClient: ReturnType<typeof getTaskWorkbenchClient>;
|
||||
task: Task;
|
||||
activeTabId: string | null;
|
||||
lastAgentTabId: string | null;
|
||||
|
|
@ -858,6 +860,7 @@ function MockWorkspaceOrgBar() {
|
|||
|
||||
export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: MockLayoutProps) {
|
||||
const navigate = useNavigate();
|
||||
const taskWorkbenchClient = useMemo(() => getTaskWorkbenchClient(workspaceId), [workspaceId]);
|
||||
const viewModel = useSyncExternalStore(
|
||||
taskWorkbenchClient.subscribe.bind(taskWorkbenchClient),
|
||||
taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient),
|
||||
|
|
@ -887,10 +890,12 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
const [activeTabIdByTask, setActiveTabIdByTask] = useState<Record<string, string | null>>({});
|
||||
const [lastAgentTabIdByTask, setLastAgentTabIdByTask] = useState<Record<string, string | null>>({});
|
||||
const [openDiffsByTask, setOpenDiffsByTask] = useState<Record<string, string[]>>({});
|
||||
const [selectedNewTaskRepoId, setSelectedNewTaskRepoId] = useState("");
|
||||
const [leftWidth, setLeftWidth] = useState(() => readStoredWidth(LEFT_WIDTH_STORAGE_KEY, LEFT_SIDEBAR_DEFAULT_WIDTH));
|
||||
const [rightWidth, setRightWidth] = useState(() => readStoredWidth(RIGHT_WIDTH_STORAGE_KEY, RIGHT_SIDEBAR_DEFAULT_WIDTH));
|
||||
const leftWidthRef = useRef(leftWidth);
|
||||
const rightWidthRef = useRef(rightWidth);
|
||||
const autoCreatingSessionForTaskRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
leftWidthRef.current = leftWidth;
|
||||
|
|
@ -1001,9 +1006,49 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
});
|
||||
}, [activeTask, lastAgentTabIdByTask, selectedSessionId, syncRouteSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNewTaskRepoId && viewModel.repos.some((repo) => repo.id === selectedNewTaskRepoId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackRepoId =
|
||||
activeTask?.repoId && viewModel.repos.some((repo) => repo.id === activeTask.repoId) ? activeTask.repoId : (viewModel.repos[0]?.id ?? "");
|
||||
if (fallbackRepoId !== selectedNewTaskRepoId) {
|
||||
setSelectedNewTaskRepoId(fallbackRepoId);
|
||||
}
|
||||
}, [activeTask?.repoId, selectedNewTaskRepoId, viewModel.repos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTask) {
|
||||
return;
|
||||
}
|
||||
if (activeTask.tabs.length > 0) {
|
||||
autoCreatingSessionForTaskRef.current.delete(activeTask.id);
|
||||
return;
|
||||
}
|
||||
if (selectedSessionId) {
|
||||
return;
|
||||
}
|
||||
if (autoCreatingSessionForTaskRef.current.has(activeTask.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoCreatingSessionForTaskRef.current.add(activeTask.id);
|
||||
void (async () => {
|
||||
try {
|
||||
const { tabId } = await taskWorkbenchClient.addTab({ taskId: activeTask.id });
|
||||
syncRouteSession(activeTask.id, tabId, true);
|
||||
} catch (error) {
|
||||
console.error("failed to auto-create workbench session", error);
|
||||
} finally {
|
||||
autoCreatingSessionForTaskRef.current.delete(activeTask.id);
|
||||
}
|
||||
})();
|
||||
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
|
||||
|
||||
const createTask = useCallback(() => {
|
||||
void (async () => {
|
||||
const repoId = activeTask?.repoId ?? viewModel.repos[0]?.id ?? "";
|
||||
const repoId = selectedNewTaskRepoId;
|
||||
if (!repoId) {
|
||||
throw new Error("Cannot create a task without an available repo");
|
||||
}
|
||||
|
|
@ -1023,7 +1068,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
search: { sessionId: tabId ?? undefined },
|
||||
});
|
||||
})();
|
||||
}, [activeTask?.repoId, navigate, viewModel.repos, workspaceId]);
|
||||
}, [navigate, selectedNewTaskRepoId, workspaceId]);
|
||||
|
||||
const openDiffTab = useCallback(
|
||||
(path: string) => {
|
||||
|
|
@ -1158,9 +1203,12 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
<div style={{ width: `${leftWidth}px`, flexShrink: 0, minWidth: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
newTaskRepos={viewModel.repos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId=""
|
||||
onSelect={selectTask}
|
||||
onCreate={createTask}
|
||||
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
|
||||
onMarkUnread={markTaskUnread}
|
||||
onRenameTask={renameTask}
|
||||
onRenameBranch={renameBranch}
|
||||
|
|
@ -1190,10 +1238,32 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first task</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>
|
||||
{viewModel.repos.length > 0
|
||||
? "Start from the sidebar to create a task on the first available repo."
|
||||
: "No repos are available in this workspace yet."}
|
||||
{viewModel.repos.length > 0 ? "Choose a repo, then create a task." : "No repos are available in this workspace yet."}
|
||||
</p>
|
||||
{viewModel.repos.length > 0 ? (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: "6px", textAlign: "left" }}>
|
||||
<span style={{ fontSize: "11px", fontWeight: 600, letterSpacing: "0.04em", textTransform: "uppercase", opacity: 0.7 }}>Repo</span>
|
||||
<select
|
||||
value={selectedNewTaskRepoId}
|
||||
onChange={(event) => {
|
||||
setSelectedNewTaskRepoId(event.currentTarget.value);
|
||||
}}
|
||||
style={{
|
||||
borderRadius: "10px",
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
padding: "10px 12px",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
color: "#f4f4f5",
|
||||
}}
|
||||
>
|
||||
{viewModel.repos.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={createTask}
|
||||
|
|
@ -1231,9 +1301,12 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
<div style={{ width: `${leftWidth}px`, flexShrink: 0, minWidth: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
newTaskRepos={viewModel.repos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId={activeTask.id}
|
||||
onSelect={selectTask}
|
||||
onCreate={createTask}
|
||||
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
|
||||
onMarkUnread={markTaskUnread}
|
||||
onRenameTask={renameTask}
|
||||
onRenameBranch={renameBranch}
|
||||
|
|
@ -1243,6 +1316,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
<PanelResizeHandle onResizeStart={onLeftResizeStart} onResize={onLeftResize} />
|
||||
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
|
||||
<TranscriptPanel
|
||||
taskWorkbenchClient={taskWorkbenchClient}
|
||||
task={activeTask}
|
||||
activeTabId={activeTabId}
|
||||
lastAgentTabId={lastAgentTabId}
|
||||
|
|
|
|||
|
|
@ -24,18 +24,24 @@ function projectIconColor(label: string): string {
|
|||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
projects,
|
||||
newTaskRepos,
|
||||
selectedNewTaskRepoId,
|
||||
activeId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onSelectNewTaskRepo,
|
||||
onMarkUnread,
|
||||
onRenameTask,
|
||||
onRenameBranch,
|
||||
onReorderProjects,
|
||||
}: {
|
||||
projects: ProjectSection[];
|
||||
newTaskRepos: Array<{ id: string; label: string }>;
|
||||
selectedNewTaskRepoId: string;
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onSelectNewTaskRepo: (repoId: string) => void;
|
||||
onMarkUnread: (id: string) => void;
|
||||
onRenameTask: (id: string) => void;
|
||||
onRenameBranch: (id: string) => void;
|
||||
|
|
@ -68,28 +74,69 @@ export const Sidebar = memo(function Sidebar({
|
|||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onCreate}
|
||||
aria-disabled={newTaskRepos.length === 0}
|
||||
onClick={() => {
|
||||
if (newTaskRepos.length === 0) {
|
||||
return;
|
||||
}
|
||||
onCreate();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (newTaskRepos.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") onCreate();
|
||||
}}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.12)",
|
||||
backgroundColor: newTaskRepos.length > 0 ? "rgba(255, 255, 255, 0.12)" : "rgba(255, 255, 255, 0.06)",
|
||||
color: "#e4e4e7",
|
||||
cursor: "pointer",
|
||||
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "background 200ms ease",
|
||||
flexShrink: 0,
|
||||
":hover": { backgroundColor: "rgba(255, 255, 255, 0.20)" },
|
||||
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
|
||||
":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined,
|
||||
})}
|
||||
>
|
||||
<Plus size={14} style={{ display: "block" }} />
|
||||
</div>
|
||||
</PanelHeaderBar>
|
||||
<div className={css({ padding: "0 8px 8px", display: "flex", flexDirection: "column", gap: "6px" })}>
|
||||
<LabelXSmall color={theme.colors.contentTertiary} $style={{ textTransform: "uppercase", letterSpacing: "0.04em" }}>
|
||||
Repo
|
||||
</LabelXSmall>
|
||||
<select
|
||||
value={selectedNewTaskRepoId}
|
||||
disabled={newTaskRepos.length === 0}
|
||||
onChange={(event) => {
|
||||
onSelectNewTaskRepo(event.currentTarget.value);
|
||||
}}
|
||||
className={css({
|
||||
width: "100%",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid rgba(255, 255, 255, 0.10)",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
color: "#f4f4f5",
|
||||
fontSize: "12px",
|
||||
padding: "8px 10px",
|
||||
outline: "none",
|
||||
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
|
||||
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
|
||||
})}
|
||||
>
|
||||
{newTaskRepos.length === 0 ? <option value="">No repos available</option> : null}
|
||||
{newTaskRepos.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<ScrollBody>
|
||||
<div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}>
|
||||
{projects.map((project, projectIndex) => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
import { createTaskWorkbenchClient } from "@sandbox-agent/foundry-client";
|
||||
import { createTaskWorkbenchClient, type TaskWorkbenchClient } from "@sandbox-agent/foundry-client";
|
||||
import { backendClient } from "./backend";
|
||||
import { defaultWorkspaceId, frontendClientMode } from "./env";
|
||||
import { frontendClientMode } from "./env";
|
||||
|
||||
export const taskWorkbenchClient = createTaskWorkbenchClient({
|
||||
mode: frontendClientMode,
|
||||
backend: backendClient,
|
||||
workspaceId: defaultWorkspaceId,
|
||||
});
|
||||
const workbenchClients = new Map<string, TaskWorkbenchClient>();
|
||||
|
||||
export function getTaskWorkbenchClient(workspaceId: string): TaskWorkbenchClient {
|
||||
const existing = workbenchClients.get(workspaceId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = createTaskWorkbenchClient({
|
||||
mode: frontendClientMode,
|
||||
backend: backendClient,
|
||||
workspaceId,
|
||||
});
|
||||
workbenchClients.set(workspaceId, created);
|
||||
return created;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
### What I Was Working On
|
||||
|
||||
Verifying the BaseUI frontend against the real `rivet-dev/task-testing` repo, creating live PR-backed tasks, and driving the flow through the browser.
|
||||
Verifying the BaseUI frontend against the real `rivet-dev/sandbox-agent-testing` repo, creating live PR-backed tasks, and driving the flow through the browser.
|
||||
|
||||
### Friction / Issue
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ Three separate issues stacked together during live verification:
|
|||
|
||||
### Outcome
|
||||
|
||||
- Live repo overview now shows the real `task-testing` PRs again.
|
||||
- Live repo overview now shows the real `sandbox-agent-testing` PRs again.
|
||||
- The stale task actor no longer blocks repo overview polling.
|
||||
- The remaining blocker is narrowed to the frontend create-task interaction path, plus missing agent API credentials for exercising real agent messaging end to end.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue