mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 13:04:11 +00:00
Improve Foundry auth and task flows (#240)
This commit is contained in:
parent
d75e8c31d1
commit
dbc2ff0682
26 changed files with 621 additions and 137 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue