diff --git a/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts b/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts index 73abea2..3049bb4 100644 --- a/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts +++ b/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts @@ -64,6 +64,8 @@ function taskSummaryRowFromSummary(taskSummary: WorkspaceTaskSummary) { branch: taskSummary.branch, pullRequestJson: JSON.stringify(taskSummary.pullRequest), sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary), + primaryUserLogin: taskSummary.primaryUserLogin ?? null, + primaryUserAvatarUrl: taskSummary.primaryUserAvatarUrl ?? null, }; } @@ -78,6 +80,8 @@ export function taskSummaryFromRow(repoId: string, row: any): WorkspaceTaskSumma branch: row.branch ?? null, pullRequest: parseJsonValue(row.pullRequestJson, null), sessionsSummary: parseJsonValue(row.sessionsSummaryJson, []), + primaryUserLogin: row.primaryUserLogin ?? null, + primaryUserAvatarUrl: row.primaryUserAvatarUrl ?? null, }; } diff --git a/foundry/packages/backend/src/actors/organization/actions/tasks.ts b/foundry/packages/backend/src/actors/organization/actions/tasks.ts index 118ff15..c3794e1 100644 --- a/foundry/packages/backend/src/actors/organization/actions/tasks.ts +++ b/foundry/packages/backend/src/actors/organization/actions/tasks.ts @@ -10,6 +10,7 @@ import type { TaskRecord, TaskSummary, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceDiffInput, TaskWorkspaceRenameInput, @@ -217,6 +218,16 @@ export const organizationTaskActions = { void task.publishPr({}).catch(() => {}); }, + async changeWorkspaceTaskOwner(c: any, input: TaskWorkspaceChangeOwnerInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.changeOwner({ + primaryUserId: input.targetUserId, + primaryGithubLogin: input.targetUserName, + primaryGithubEmail: input.targetUserEmail, + primaryGithubAvatarUrl: null, + }); + }, + async revertWorkspaceFile(c: any, input: TaskWorkspaceDiffInput): Promise { const task = await requireWorkspaceTask(c, input.repoId, input.taskId); void task.revertFile(input).catch(() => {}); diff --git a/foundry/packages/backend/src/actors/organization/db/migrations.ts b/foundry/packages/backend/src/actors/organization/db/migrations.ts index a7e8abc..6a19075 100644 --- a/foundry/packages/backend/src/actors/organization/db/migrations.ts +++ b/foundry/packages/backend/src/actors/organization/db/migrations.ts @@ -16,6 +16,12 @@ const journal = { tag: "0001_add_auth_and_task_tables", breakpoints: true, }, + { + idx: 2, + when: 1773984000000, + tag: "0002_add_task_owner_columns", + breakpoints: true, + }, ], } as const; @@ -165,6 +171,10 @@ CREATE TABLE \`task_summaries\` ( \`pull_request_json\` text, \`sessions_summary_json\` text DEFAULT '[]' NOT NULL ); +`, + m0002: `ALTER TABLE \`task_summaries\` ADD COLUMN \`primary_user_login\` text; +--> statement-breakpoint +ALTER TABLE \`task_summaries\` ADD COLUMN \`primary_user_avatar_url\` text; `, } as const, }; diff --git a/foundry/packages/backend/src/actors/organization/db/schema.ts b/foundry/packages/backend/src/actors/organization/db/schema.ts index 5071a25..3978a5f 100644 --- a/foundry/packages/backend/src/actors/organization/db/schema.ts +++ b/foundry/packages/backend/src/actors/organization/db/schema.ts @@ -40,6 +40,8 @@ export const taskSummaries = sqliteTable("task_summaries", { branch: text("branch"), pullRequestJson: text("pull_request_json"), sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"), + primaryUserLogin: text("primary_user_login"), + primaryUserAvatarUrl: text("primary_user_avatar_url"), }); export const organizationProfile = sqliteTable( diff --git a/foundry/packages/backend/src/actors/task/db/migrations.ts b/foundry/packages/backend/src/actors/task/db/migrations.ts index 1e6ff76..61b0dff 100644 --- a/foundry/packages/backend/src/actors/task/db/migrations.ts +++ b/foundry/packages/backend/src/actors/task/db/migrations.ts @@ -10,6 +10,12 @@ const journal = { tag: "0000_charming_maestro", breakpoints: true, }, + { + idx: 1, + when: 1773984000000, + tag: "0001_add_task_owner", + breakpoints: true, + }, ], } as const; @@ -65,6 +71,16 @@ CREATE TABLE \`task_workspace_sessions\` ( \`created_at\` integer NOT NULL, \`updated_at\` integer NOT NULL ); +`, + m0001: `CREATE TABLE \`task_owner\` ( + \`id\` integer PRIMARY KEY NOT NULL, + \`primary_user_id\` text, + \`primary_github_login\` text, + \`primary_github_email\` text, + \`primary_github_avatar_url\` text, + \`updated_at\` integer NOT NULL, + CONSTRAINT "task_owner_singleton_id_check" CHECK("task_owner"."id" = 1) +); `, } as const, }; diff --git a/foundry/packages/backend/src/actors/task/db/schema.ts b/foundry/packages/backend/src/actors/task/db/schema.ts index 651ff76..bdb7cf7 100644 --- a/foundry/packages/backend/src/actors/task/db/schema.ts +++ b/foundry/packages/backend/src/actors/task/db/schema.ts @@ -47,6 +47,24 @@ export const taskSandboxes = sqliteTable("task_sandboxes", { updatedAt: integer("updated_at").notNull(), }); +/** + * Single-row table tracking the primary user (owner) of this task. + * The owner's GitHub OAuth credentials are injected into the sandbox + * for git operations. Updated when a different user sends a message. + */ +export const taskOwner = sqliteTable( + "task_owner", + { + id: integer("id").primaryKey(), + primaryUserId: text("primary_user_id"), + primaryGithubLogin: text("primary_github_login"), + primaryGithubEmail: text("primary_github_email"), + primaryGithubAvatarUrl: text("primary_github_avatar_url"), + updatedAt: integer("updated_at").notNull(), + }, + (table) => [check("task_owner_singleton_id_check", sql`${table.id} = 1`)], +); + /** * Coordinator index of workspace sessions within this task. * The task actor is the coordinator for sessions. Each row holds session diff --git a/foundry/packages/backend/src/actors/task/workflow/index.ts b/foundry/packages/backend/src/actors/task/workflow/index.ts index 69004ee..de25813 100644 --- a/foundry/packages/backend/src/actors/task/workflow/index.ts +++ b/foundry/packages/backend/src/actors/task/workflow/index.ts @@ -12,6 +12,7 @@ import { killWriteDbActivity, } from "./commands.js"; import { + changeTaskOwnerManually, changeWorkspaceModel, closeWorkspaceSession, createWorkspaceSession, @@ -176,6 +177,16 @@ export const taskCommandActions = { return { ok: true }; }, + async changeOwner(c: any, body: any) { + await changeTaskOwnerManually(c, { + primaryUserId: body.primaryUserId, + primaryGithubLogin: body.primaryGithubLogin, + primaryGithubEmail: body.primaryGithubEmail, + primaryGithubAvatarUrl: body.primaryGithubAvatarUrl ?? null, + }); + return { ok: true }; + }, + async createSession(c: any, body: any) { return await createWorkspaceSession(c, body?.model, body?.authSessionId); }, diff --git a/foundry/packages/backend/src/actors/task/workspace.ts b/foundry/packages/backend/src/actors/task/workspace.ts index 7505d01..ac2430f 100644 --- a/foundry/packages/backend/src/actors/task/workspace.ts +++ b/foundry/packages/backend/src/actors/task/workspace.ts @@ -19,7 +19,7 @@ import { resolveOrganizationGithubAuth } from "../../services/github-auth.js"; import { githubRepoFullNameFromRemote } from "../../services/repo.js"; // organization actions called directly (no queue) -import { task as taskTable, taskRuntime, taskSandboxes, taskWorkspaceSessions } from "./db/schema.js"; +import { task as taskTable, taskOwner, taskRuntime, taskSandboxes, taskWorkspaceSessions } from "./db/schema.js"; import { getCurrentRecord } from "./workflow/common.js"; function emptyGitState() { @@ -123,6 +123,193 @@ function parseGitState(value: string | null | undefined): { fileChanges: Array { + const row = await c.db.select().from(taskOwner).where(eq(taskOwner.id, 1)).get(); + if (!row) { + return null; + } + return { + primaryUserId: row.primaryUserId ?? null, + primaryGithubLogin: row.primaryGithubLogin ?? null, + primaryGithubEmail: row.primaryGithubEmail ?? null, + primaryGithubAvatarUrl: row.primaryGithubAvatarUrl ?? null, + }; +} + +async function upsertTaskOwner( + c: any, + owner: { primaryUserId: string; primaryGithubLogin: string; primaryGithubEmail: string; primaryGithubAvatarUrl: string | null }, +): Promise { + const now = Date.now(); + await c.db + .insert(taskOwner) + .values({ + id: 1, + primaryUserId: owner.primaryUserId, + primaryGithubLogin: owner.primaryGithubLogin, + primaryGithubEmail: owner.primaryGithubEmail, + primaryGithubAvatarUrl: owner.primaryGithubAvatarUrl, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: taskOwner.id, + set: { + primaryUserId: owner.primaryUserId, + primaryGithubLogin: owner.primaryGithubLogin, + primaryGithubEmail: owner.primaryGithubEmail, + primaryGithubAvatarUrl: owner.primaryGithubAvatarUrl, + updatedAt: now, + }, + }) + .run(); +} + +/** + * Inject the user's GitHub OAuth token into the sandbox as a git credential store file. + * Also configures git user.name and user.email so commits are attributed correctly. + * The credential file is overwritten on each owner swap. + * + * Race condition note: If User A sends a message and the agent starts a long git operation, + * then User B triggers an owner swap, the in-flight git process still has User A's credentials + * (already read from the credential store). The next git operation uses User B's credentials. + */ +async function injectGitCredentials(sandbox: any, login: string, email: string, token: string): Promise { + const script = [ + "set -euo pipefail", + `git config --global user.name ${JSON.stringify(login)}`, + `git config --global user.email ${JSON.stringify(email)}`, + `git config --global credential.helper 'store --file=/home/user/.git-token'`, + `printf '%s\\n' ${JSON.stringify(`https://${login}:${token}@github.com`)} > /home/user/.git-token`, + `chmod 600 /home/user/.git-token`, + ]; + const result = await sandbox.runProcess({ + command: "bash", + args: ["-lc", script.join("; ")], + cwd: "/", + timeoutMs: 30_000, + }); + if ((result.exitCode ?? 0) !== 0) { + logActorWarning("task", "git credential injection failed", { + exitCode: result.exitCode, + output: [result.stdout, result.stderr].filter(Boolean).join(""), + }); + } +} + +/** + * Resolves the current user's GitHub identity from their auth session. + * Returns null if the session is invalid or the user has no GitHub account. + */ +async function resolveGithubIdentity(authSessionId: string): Promise<{ + userId: string; + login: string; + email: string; + avatarUrl: string | null; + accessToken: string; +} | null> { + const authService = getBetterAuthService(); + const authState = await authService.getAuthState(authSessionId); + if (!authState?.user?.id) { + return null; + } + + const tokenResult = await authService.getAccessTokenForSession(authSessionId); + if (!tokenResult?.accessToken) { + return null; + } + + const githubAccount = authState.accounts?.find((account: any) => account.providerId === "github"); + if (!githubAccount) { + return null; + } + + // Resolve the GitHub login from the API since Better Auth only stores the + // numeric account ID, not the login username. + let login = authState.user.name ?? "unknown"; + let avatarUrl = authState.user.image ?? null; + try { + const resp = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokenResult.accessToken}`, + Accept: "application/vnd.github+json", + }, + }); + if (resp.ok) { + const ghUser = (await resp.json()) as { login?: string; avatar_url?: string }; + if (ghUser.login) { + login = ghUser.login; + } + if (ghUser.avatar_url) { + avatarUrl = ghUser.avatar_url; + } + } + } catch (error) { + console.warn("resolveGithubIdentity: failed to fetch GitHub user", error); + } + + return { + userId: authState.user.id, + login, + email: authState.user.email ?? `${githubAccount.accountId}@users.noreply.github.com`, + avatarUrl, + accessToken: tokenResult.accessToken, + }; +} + +/** + * Check if the task owner needs to swap, and if so, update the owner record + * and inject new git credentials into the sandbox. + * Returns true if an owner swap occurred. + */ +async function maybeSwapTaskOwner(c: any, authSessionId: string | null | undefined, sandbox: any | null): Promise { + if (!authSessionId) { + return false; + } + + const identity = await resolveGithubIdentity(authSessionId); + if (!identity) { + return false; + } + + const currentOwner = await readTaskOwner(c); + if (currentOwner?.primaryUserId === identity.userId) { + return false; + } + + await upsertTaskOwner(c, { + primaryUserId: identity.userId, + primaryGithubLogin: identity.login, + primaryGithubEmail: identity.email, + primaryGithubAvatarUrl: identity.avatarUrl, + }); + + if (sandbox) { + await injectGitCredentials(sandbox, identity.login, identity.email, identity.accessToken); + } + + return true; +} + +/** + * Manually change the task owner. Updates the owner record and broadcasts the + * change to subscribers. Git credentials are NOT injected here — they will be + * injected the next time the target user sends a message (auto-swap path). + */ +export async function changeTaskOwnerManually( + c: any, + input: { primaryUserId: string; primaryGithubLogin: string; primaryGithubEmail: string; primaryGithubAvatarUrl: string | null }, +): Promise { + await upsertTaskOwner(c, input); + await broadcastTaskUpdate(c); +} + export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean { if (status === "running") { return false; @@ -443,7 +630,7 @@ async function getTaskSandboxRuntime( */ let sandboxRepoPrepared = false; -async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { skipFetchIfPrepared?: boolean }): Promise { +async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { skipFetchIfPrepared?: boolean; authSessionId?: string | null }): Promise { if (!record.branchName) { throw new Error("cannot prepare a sandbox repo before the task branch exists"); } @@ -489,6 +676,12 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { ski throw new Error(`sandbox repo preparation failed (${result.exitCode ?? 1}): ${[result.stdout, result.stderr].filter(Boolean).join("")}`); } + // On first repo preparation, inject the task owner's git credentials into the sandbox + // so that push/commit operations are authenticated and attributed to the correct user. + if (!sandboxRepoPrepared && opts?.authSessionId) { + await maybeSwapTaskOwner(c, opts.authSessionId, sandbox); + } + sandboxRepoPrepared = true; } @@ -862,6 +1055,8 @@ export async function buildTaskSummary(c: any, authSessionId?: string | null): P const activeSessionId = userTaskState.activeSessionId && sessions.some((meta) => meta.sessionId === userTaskState.activeSessionId) ? userTaskState.activeSessionId : null; + const owner = await readTaskOwner(c); + return { id: c.state.taskId, repoId: c.state.repoId, @@ -873,6 +1068,8 @@ export async function buildTaskSummary(c: any, authSessionId?: string | null): P pullRequest: record.pullRequest ?? null, activeSessionId, sessionsSummary: sessions.map((meta) => buildSessionSummary(meta, userTaskState.bySessionId.get(meta.sessionId))), + primaryUserLogin: owner?.primaryGithubLogin ?? null, + primaryUserAvatarUrl: owner?.primaryGithubAvatarUrl ?? null, }; } @@ -1212,7 +1409,14 @@ export async function sendWorkspaceMessage(c: any, sessionId: string, text: stri const runtime = await getTaskSandboxRuntime(c, record); // Skip git fetch on subsequent messages — the repo was already prepared during session // creation. This avoids a 5-30s network round-trip to GitHub on every prompt. - await ensureSandboxRepo(c, runtime.sandbox, record, { skipFetchIfPrepared: true }); + await ensureSandboxRepo(c, runtime.sandbox, record, { skipFetchIfPrepared: true, authSessionId }); + + // Check if the task owner needs to swap. If a different user is sending this message, + // update the owner record and inject their git credentials into the sandbox. + const ownerSwapped = await maybeSwapTaskOwner(c, authSessionId, runtime.sandbox); + if (ownerSwapped) { + await broadcastTaskUpdate(c); + } const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)].filter( Boolean, ); diff --git a/foundry/packages/client/src/backend-client.ts b/foundry/packages/client/src/backend-client.ts index 0903aa8..c2222cc 100644 --- a/foundry/packages/client/src/backend-client.ts +++ b/foundry/packages/client/src/backend-client.ts @@ -12,6 +12,7 @@ import type { TaskRecord, TaskSummary, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceCreateTaskResponse, TaskWorkspaceDiffInput, @@ -110,6 +111,7 @@ interface OrganizationHandle { stopWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise; closeWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise; publishWorkspacePr(input: TaskWorkspaceSelectInput & AuthSessionScopedInput): Promise; + changeWorkspaceTaskOwner(input: TaskWorkspaceChangeOwnerInput & AuthSessionScopedInput): Promise; revertWorkspaceFile(input: TaskWorkspaceDiffInput & AuthSessionScopedInput): Promise; adminReloadGithubOrganization(): Promise; adminReloadGithubRepository(input: { repoId: string }): Promise; @@ -304,6 +306,7 @@ export interface BackendClient { stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise; closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise; publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise; + changeWorkspaceTaskOwner(organizationId: string, input: TaskWorkspaceChangeOwnerInput): Promise; revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise; adminReloadGithubOrganization(organizationId: string): Promise; adminReloadGithubRepository(organizationId: string, repoId: string): Promise; @@ -1282,6 +1285,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien await (await organization(organizationId)).publishWorkspacePr(await withAuthSessionInput(input)); }, + async changeWorkspaceTaskOwner(organizationId: string, input: TaskWorkspaceChangeOwnerInput): Promise { + await (await organization(organizationId)).changeWorkspaceTaskOwner(await withAuthSessionInput(input)); + }, + async revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise { await (await organization(organizationId)).revertWorkspaceFile(await withAuthSessionInput(input)); }, diff --git a/foundry/packages/client/src/mock/backend-client.ts b/foundry/packages/client/src/mock/backend-client.ts index fc6470c..5ef675d 100644 --- a/foundry/packages/client/src/mock/backend-client.ts +++ b/foundry/packages/client/src/mock/backend-client.ts @@ -188,6 +188,8 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back unread: tab.unread, created: tab.created, })), + primaryUserLogin: null, + primaryUserAvatarUrl: null, }); const buildTaskDetail = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskDetail => ({ @@ -750,6 +752,15 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back emitTaskUpdate(input.taskId); }, + async changeWorkspaceTaskOwner( + _organizationId: string, + input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }, + ): Promise { + await workspace.changeOwner(input); + emitOrganizationSnapshot(); + emitTaskUpdate(input.taskId); + }, + async revertWorkspaceFile(_organizationId: string, input: TaskWorkspaceDiffInput): Promise { await workspace.revertFile(input); emitOrganizationSnapshot(); diff --git a/foundry/packages/client/src/mock/workspace-client.ts b/foundry/packages/client/src/mock/workspace-client.ts index c51b2e8..7983e0f 100644 --- a/foundry/packages/client/src/mock/workspace-client.ts +++ b/foundry/packages/client/src/mock/workspace-client.ts @@ -349,7 +349,10 @@ class MockWorkspaceStore implements TaskWorkspaceClient { return { ...currentTask, - activeSessionId: currentTask.activeSessionId === input.sessionId ? (currentTask.sessions.find((candidate) => candidate.id !== input.sessionId)?.id ?? null) : currentTask.activeSessionId, + activeSessionId: + currentTask.activeSessionId === input.sessionId + ? (currentTask.sessions.find((candidate) => candidate.id !== input.sessionId)?.id ?? null) + : currentTask.activeSessionId, sessions: currentTask.sessions.filter((candidate) => candidate.id !== input.sessionId), }; }); @@ -396,6 +399,14 @@ class MockWorkspaceStore implements TaskWorkspaceClient { })); } + async changeOwner(input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }): Promise { + this.updateTask(input.taskId, (currentTask) => ({ + ...currentTask, + primaryUserLogin: input.targetUserName, + primaryUserAvatarUrl: null, + })); + } + private updateState(updater: (current: TaskWorkspaceSnapshot) => TaskWorkspaceSnapshot): void { const nextSnapshot = updater(this.snapshot); this.snapshot = { diff --git a/foundry/packages/client/src/remote/workspace-client.ts b/foundry/packages/client/src/remote/workspace-client.ts index 1b6bc8e..2a11f51 100644 --- a/foundry/packages/client/src/remote/workspace-client.ts +++ b/foundry/packages/client/src/remote/workspace-client.ts @@ -1,6 +1,7 @@ import type { TaskWorkspaceAddSessionResponse, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceCreateTaskResponse, TaskWorkspaceDiffInput, @@ -140,6 +141,11 @@ class RemoteWorkspaceStore implements TaskWorkspaceClient { await this.refresh(); } + async changeOwner(input: TaskWorkspaceChangeOwnerInput): Promise { + await this.backend.changeWorkspaceTaskOwner(this.organizationId, input); + await this.refresh(); + } + private ensureStarted(): void { if (!this.unsubscribeWorkspace) { this.unsubscribeWorkspace = this.backend.subscribeWorkspace(this.organizationId, () => { diff --git a/foundry/packages/client/src/workspace-client.ts b/foundry/packages/client/src/workspace-client.ts index c3293a0..6662352 100644 --- a/foundry/packages/client/src/workspace-client.ts +++ b/foundry/packages/client/src/workspace-client.ts @@ -1,6 +1,7 @@ import type { TaskWorkspaceAddSessionResponse, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceCreateTaskResponse, TaskWorkspaceDiffInput, @@ -43,6 +44,7 @@ export interface TaskWorkspaceClient { closeSession(input: TaskWorkspaceSessionInput): Promise; addSession(input: TaskWorkspaceSelectInput): Promise; changeModel(input: TaskWorkspaceChangeModelInput): Promise; + changeOwner(input: TaskWorkspaceChangeOwnerInput): Promise; } export function createTaskWorkspaceClient(options: CreateTaskWorkspaceClientOptions): TaskWorkspaceClient { diff --git a/foundry/packages/client/test/subscription-manager.test.ts b/foundry/packages/client/test/subscription-manager.test.ts index c064606..f0a29c2 100644 --- a/foundry/packages/client/test/subscription-manager.test.ts +++ b/foundry/packages/client/test/subscription-manager.test.ts @@ -77,6 +77,8 @@ function organizationSnapshot(): OrganizationSummarySnapshot { pullRequest: null, activeSessionId: null, sessionsSummary: [], + primaryUserLogin: null, + primaryUserAvatarUrl: null, }, ], }; @@ -159,6 +161,8 @@ describe("RemoteSubscriptionManager", () => { pullRequest: null, activeSessionId: null, sessionsSummary: [], + primaryUserLogin: null, + primaryUserAvatarUrl: null, }, ], }, diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx index 042b5a4..797b650 100644 --- a/foundry/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -42,7 +42,7 @@ import { type Message, type ModelId, } from "./mock-layout/view-model"; -import { activeMockOrganization, activeMockUser, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; +import { activeMockOrganization, activeMockUser, getMockOrganizationById, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; import { backendClient } from "../lib/backend"; import { subscriptionManager } from "../lib/subscription"; import { describeTaskState, isProvisioningTaskStatus } from "../features/tasks/status"; @@ -188,6 +188,8 @@ function toTaskModel( fileTree: detail?.fileTree ?? [], minutesUsed: detail?.minutesUsed ?? 0, activeSandboxId: detail?.activeSandboxId ?? null, + primaryUserLogin: detail?.primaryUserLogin ?? summary.primaryUserLogin ?? null, + primaryUserAvatarUrl: detail?.primaryUserAvatarUrl ?? summary.primaryUserAvatarUrl ?? null, }; } @@ -264,6 +266,7 @@ interface WorkspaceActions { closeSession(input: { repoId: string; taskId: string; sessionId: string }): Promise; addSession(input: { repoId: string; taskId: string; model?: string }): Promise<{ sessionId: string }>; changeModel(input: { repoId: string; taskId: string; sessionId: string; model: ModelId }): Promise; + changeOwner(input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }): Promise; adminReloadGithubOrganization(): Promise; adminReloadGithubRepository(repoId: string): Promise; } @@ -1069,6 +1072,8 @@ const RightRail = memo(function RightRail({ onArchive, onRevertFile, onPublishPr, + onChangeOwner, + members, onToggleSidebar, }: { organizationId: string; @@ -1078,6 +1083,8 @@ const RightRail = memo(function RightRail({ onArchive: () => void; onRevertFile: (path: string) => void; onPublishPr: () => void; + onChangeOwner: (member: { id: string; name: string; email: string }) => void; + members: Array<{ id: string; name: string; email: string }>; onToggleSidebar?: () => void; }) { const [css] = useStyletron(); @@ -1170,6 +1177,8 @@ const RightRail = memo(function RightRail({ onArchive={onArchive} onRevertFile={onRevertFile} onPublishPr={onPublishPr} + onChangeOwner={onChangeOwner} + members={members} onToggleSidebar={onToggleSidebar} /> @@ -1311,6 +1320,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } closeSession: (input) => backendClient.closeWorkspaceSession(organizationId, input), addSession: (input) => backendClient.createWorkspaceSession(organizationId, input), changeModel: (input) => backendClient.changeWorkspaceModel(organizationId, input), + changeOwner: (input) => backendClient.changeWorkspaceTaskOwner(organizationId, input), adminReloadGithubOrganization: () => backendClient.adminReloadGithubOrganization(organizationId), adminReloadGithubRepository: (repoId) => backendClient.adminReloadGithubRepository(organizationId, repoId), }), @@ -1741,6 +1751,22 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } [tasks], ); + const changeOwner = useCallback( + (member: { id: string; name: string; email: string }) => { + if (!activeTask) { + throw new Error("Cannot change owner without an active task"); + } + void taskWorkspaceClient.changeOwner({ + repoId: activeTask.repoId, + taskId: activeTask.id, + targetUserId: member.id, + targetUserName: member.name, + targetUserEmail: member.email, + }); + }, + [activeTask], + ); + const archiveTask = useCallback(() => { if (!activeTask) { throw new Error("Cannot archive without an active task"); @@ -2167,6 +2193,8 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } onArchive={archiveTask} onRevertFile={revertFile} onPublishPr={publishPr} + onChangeOwner={changeOwner} + members={getMockOrganizationById(appSnapshot, organizationId)?.members ?? []} onToggleSidebar={() => setRightSidebarOpen(false)} /> diff --git a/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx b/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx index cd4c33a..ca7b580 100644 --- a/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx @@ -1,7 +1,20 @@ -import { memo, useCallback, useMemo, useState, type MouseEvent } from "react"; +import { memo, useCallback, useMemo, useRef, useState, type MouseEvent } from "react"; import { useStyletron } from "baseui"; -import { LabelSmall } from "baseui/typography"; -import { Archive, ArrowUpFromLine, ChevronRight, FileCode, FilePlus, FileX, FolderOpen, GitPullRequest, PanelRight } from "lucide-react"; +import { LabelSmall, LabelXSmall } from "baseui/typography"; +import { + Archive, + ArrowUpFromLine, + ChevronDown, + ChevronRight, + FileCode, + FilePlus, + FileX, + FolderOpen, + GitBranch, + GitPullRequest, + PanelRight, + User, +} from "lucide-react"; import { useFoundryTokens } from "../../app/theme"; import { createErrorContext } from "@sandbox-agent/foundry-shared"; @@ -99,6 +112,8 @@ export const RightSidebar = memo(function RightSidebar({ onArchive, onRevertFile, onPublishPr, + onChangeOwner, + members, onToggleSidebar, }: { task: Task; @@ -107,11 +122,13 @@ export const RightSidebar = memo(function RightSidebar({ onArchive: () => void; onRevertFile: (path: string) => void; onPublishPr: () => void; + onChangeOwner: (member: { id: string; name: string; email: string }) => void; + members: Array<{ id: string; name: string; email: string }>; onToggleSidebar?: () => void; }) { const [css] = useStyletron(); const t = useFoundryTokens(); - const [rightTab, setRightTab] = useState<"changes" | "files">("changes"); + const [rightTab, setRightTab] = useState<"overview" | "changes" | "files">("changes"); const contextMenu = useContextMenu(); const changedPaths = useMemo(() => new Set(task.fileChanges.map((file) => file.path)), [task.fileChanges]); const isTerminal = task.status === "archived"; @@ -125,6 +142,8 @@ export const RightSidebar = memo(function RightSidebar({ }); observer.observe(node); }, []); + const [ownerDropdownOpen, setOwnerDropdownOpen] = useState(false); + const ownerDropdownRef = useRef(null); const pullRequestUrl = task.pullRequest?.url ?? null; const copyFilePath = useCallback(async (path: string) => { @@ -310,7 +329,7 @@ export const RightSidebar = memo(function RightSidebar({ })} > +