mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 07:04:48 +00:00
feat(foundry): task owner git auth + manual owner change UI (#263)
* Add task owner git auth proposal and sandbox architecture docs - Add proposal for primary user per task with OAuth token injection for sandbox git operations (.context/proposal-task-owner-git-auth.md) - Document sandbox architecture constraints in CLAUDE.md: single sandbox per task assumption, OAuth token security implications, git auto-auth requirement, and git error surfacing rules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add proposals for reverting to queues and rivetkit sandbox resilience - proposal-revert-actions-to-queues.md: Detailed plan for reverting the actions-only pattern back to queues/workflows now that the RivetKit queue.iter() bug is fixed. Lists what to keep (lazy tasks, resolveTaskRepoId, sync override threading, E2B fixes, frontend fixes) vs what to revert (communication pattern only). - proposal-rivetkit-sandbox-resilience.md: Rivetkit sandbox actor changes for handling destroyed/paused sandboxes, keep-alive, and the UNIQUE constraint crash fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(foundry): add manual task owner change via UI dropdown Add an owner dropdown to the Overview tab that lets users reassign task ownership to any organization member. The owner's GitHub credentials are used for git operations in the sandbox. Full-stack implementation: - Backend: changeTaskOwnerManually action on task actor, routed through org actor's changeWorkspaceTaskOwner action, with primaryUser schema columns on both task and org index tables - Client: changeOwner method on workspace client (mock + remote) - Frontend: owner dropdown in right sidebar Overview tab showing org members, with avatar and role display - Shared: TaskWorkspaceChangeOwnerInput type and primaryUser fields on workspace snapshot types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
167712ace7
commit
4111aebfce
22 changed files with 1114 additions and 11 deletions
165
.context/proposal-revert-actions-to-queues.md
Normal file
165
.context/proposal-revert-actions-to-queues.md
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
# Proposal: Revert Actions-Only Pattern Back to Queues/Workflows
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
We converted all actors from queue/workflow-based communication to direct actions as a workaround for a RivetKit bug where `c.queue.iter()` deadlocked for actors created from another actor's context. That bug has since been fixed in RivetKit. We want to revert to queues/workflows because they provide better observability (workflow history in the inspector), replay/recovery semantics, and are the idiomatic RivetKit pattern.
|
||||||
|
|
||||||
|
## Reference branches
|
||||||
|
|
||||||
|
- **`main`** at commit `32f3c6c3` — the original queue/workflow code BEFORE the actions refactor
|
||||||
|
- **`queues-to-actions`** — the current refactored code using direct actions
|
||||||
|
- **`task-owner-git-auth`** at commit `f45a4674` — the merged PR #262 that introduced the actions pattern
|
||||||
|
|
||||||
|
Use `main` as the reference for the queue/workflow communication patterns. Use `queues-to-actions` as the reference for bug fixes and new features that MUST be preserved.
|
||||||
|
|
||||||
|
## What to KEEP (do NOT revert these)
|
||||||
|
|
||||||
|
These are bug fixes and improvements made during the actions refactor that are independent of the communication pattern:
|
||||||
|
|
||||||
|
### 1. Lazy task actor creation
|
||||||
|
- Virtual task entries in org's `taskIndex` + `taskSummaries` tables (no actor fan-out during PR sync)
|
||||||
|
- `refreshTaskSummaryForBranchMutation` writes directly to org tables instead of spawning task actors
|
||||||
|
- Task actors self-initialize in `getCurrentRecord()` from `getTaskIndexEntry` when lazily created
|
||||||
|
- `getTaskIndexEntry` action on org actor
|
||||||
|
- See CLAUDE.md "Lazy Task Actor Creation" section
|
||||||
|
|
||||||
|
### 2. `resolveTaskRepoId` replacing `requireRepoExists`
|
||||||
|
- `requireRepoExists` was removed — it did a cross-actor call from org to github-data that was fragile
|
||||||
|
- Replaced with `resolveTaskRepoId` which reads from the org's local `taskIndex` table
|
||||||
|
- `getTask` action resolves `repoId` from task index when not provided (sandbox actor only has taskId)
|
||||||
|
|
||||||
|
### 3. `getOrganizationContext` overrides threaded through sync phases
|
||||||
|
- `fullSyncBranchBatch`, `fullSyncMembers`, `fullSyncPullRequestBatch` now pass `connectedAccount`, `installationStatus`, `installationId` overrides from `FullSyncConfig`
|
||||||
|
- Without this, phases 2-4 fail with "Organization not initialized" when the org profile doesn't exist yet (webhook-triggered sync before user sign-in)
|
||||||
|
|
||||||
|
### 4. E2B sandbox fixes
|
||||||
|
- `timeoutMs: 60 * 60 * 1000` in E2B create options (TEMPORARY until rivetkit autoPause lands)
|
||||||
|
- Sandbox repo path uses `/home/user/repo` for E2B compatibility
|
||||||
|
- `listProcesses` error handling for expired E2B sandboxes
|
||||||
|
|
||||||
|
### 5. Frontend fixes
|
||||||
|
- React `useEffect` dependency stability in `mock-layout.tsx` and `organization-dashboard.tsx` (prevents infinite re-render loops)
|
||||||
|
- Terminal pane ref handling
|
||||||
|
|
||||||
|
### 6. Process crash protection
|
||||||
|
- `process.on("uncaughtException")` and `process.on("unhandledRejection")` handlers in `foundry/packages/backend/src/index.ts`
|
||||||
|
|
||||||
|
### 7. CLAUDE.md updates
|
||||||
|
- All new sections: lazy task creation rules, no-silent-catch policy, React hook dependency safety, dev workflow instructions, debugging section
|
||||||
|
|
||||||
|
### 8. `requireWorkspaceTask` uses `getOrCreate`
|
||||||
|
- User-initiated actions (createSession, sendMessage, etc.) use `getOrCreate` to lazily materialize virtual tasks
|
||||||
|
- The `getOrCreate` call passes `{ organizationId, repoId, taskId }` as `createWithInput`
|
||||||
|
|
||||||
|
### 9. `getTask` uses `getOrCreate` with `resolveTaskRepoId`
|
||||||
|
- When `repoId` is not provided (sandbox actor), resolves from task index
|
||||||
|
- Uses `getOrCreate` since the task may be virtual
|
||||||
|
|
||||||
|
### 10. Audit log deleted workflow file
|
||||||
|
- `foundry/packages/backend/src/actors/audit-log/workflow.ts` was deleted
|
||||||
|
- The audit-log actor was simplified to a single `append` action
|
||||||
|
- Keep this simplification — audit-log doesn't need a workflow
|
||||||
|
|
||||||
|
## What to REVERT (communication pattern only)
|
||||||
|
|
||||||
|
For each actor, revert from direct action calls back to queue sends with `expectQueueResponse` / fire-and-forget patterns. The reference for the queue patterns is `main` at `32f3c6c3`.
|
||||||
|
|
||||||
|
### 1. Organization actor (`foundry/packages/backend/src/actors/organization/`)
|
||||||
|
|
||||||
|
**`index.ts`:**
|
||||||
|
- Revert from actions-only to `run: workflow(runOrganizationWorkflow)`
|
||||||
|
- Keep the actions that are pure reads (getAppSnapshot, getOrganizationSummarySnapshot, etc.)
|
||||||
|
- Mutations should go through the workflow queue command loop
|
||||||
|
|
||||||
|
**`workflow.ts`:**
|
||||||
|
- Restore `runOrganizationWorkflow` with the `ctx.loop("organization-command-loop", ...)` that dispatches queue names to mutation handlers
|
||||||
|
- Restore `ORGANIZATION_QUEUE_NAMES` and `COMMAND_HANDLERS`
|
||||||
|
- Restore `organizationWorkflowQueueName()` helper
|
||||||
|
|
||||||
|
**`app-shell.ts`:**
|
||||||
|
- Revert direct action calls back to queue sends: `sendOrganizationCommand(org, "organization.command.X", body)` pattern
|
||||||
|
- Revert `githubData.syncRepos(...)` → `githubData.send(githubDataWorkflowQueueName("syncRepos"), ...)`
|
||||||
|
- But KEEP the `getOrganizationContext` override threading fix
|
||||||
|
|
||||||
|
**`actions/tasks.ts`:**
|
||||||
|
- Keep `resolveTaskRepoId` (replacing `requireRepoExists`)
|
||||||
|
- Keep `requireWorkspaceTask` using `getOrCreate`
|
||||||
|
- Keep `getTask` using `getOrCreate` with `resolveTaskRepoId`
|
||||||
|
- Keep `getTaskIndexEntry`
|
||||||
|
- Revert task actor calls from direct actions to queue sends where applicable
|
||||||
|
|
||||||
|
**`actions/task-mutations.ts`:**
|
||||||
|
- Keep lazy task creation (virtual entries in org tables)
|
||||||
|
- Revert `taskHandle.initialize(...)` → `taskHandle.send(taskWorkflowQueueName("task.command.initialize"), ...)`
|
||||||
|
- Revert `task.pullRequestSync(...)` → `task.send(taskWorkflowQueueName("task.command.pullRequestSync"), ...)`
|
||||||
|
- Revert `auditLog.append(...)` → `auditLog.send("auditLog.command.append", ...)`
|
||||||
|
|
||||||
|
**`actions/organization.ts`:**
|
||||||
|
- Revert direct calls to org workflow back to queue sends
|
||||||
|
|
||||||
|
**`actions/github.ts`:**
|
||||||
|
- Revert direct calls back to queue sends
|
||||||
|
|
||||||
|
### 2. Task actor (`foundry/packages/backend/src/actors/task/`)
|
||||||
|
|
||||||
|
**`index.ts`:**
|
||||||
|
- Revert from actions-only to `run: workflow(runTaskWorkflow)` (or plain `run` with queue iteration)
|
||||||
|
- Keep read actions: `get`, `getTaskSummary`, `getTaskDetail`, `getSessionDetail`
|
||||||
|
|
||||||
|
**`workflow/index.ts`:**
|
||||||
|
- Restore `taskCommandActions` as queue handlers in the workflow command loop
|
||||||
|
- Restore `TASK_QUEUE_NAMES` and dispatch map
|
||||||
|
|
||||||
|
**`workspace.ts`:**
|
||||||
|
- Revert sandbox/org action calls back to queue sends where they were queue-based before
|
||||||
|
|
||||||
|
### 3. User actor (`foundry/packages/backend/src/actors/user/`)
|
||||||
|
|
||||||
|
**`index.ts`:**
|
||||||
|
- Revert from actions-only to `run: workflow(runUserWorkflow)` (or plain run with queue iteration)
|
||||||
|
|
||||||
|
**`workflow.ts`:**
|
||||||
|
- Restore queue command loop dispatching to mutation functions
|
||||||
|
|
||||||
|
### 4. GitHub-data actor (`foundry/packages/backend/src/actors/github-data/`)
|
||||||
|
|
||||||
|
**`index.ts`:**
|
||||||
|
- Revert from actions-only to having a run handler with queue iteration
|
||||||
|
- Keep the `getOrganizationContext` override threading fix
|
||||||
|
- Keep the `actionTimeout: 10 * 60_000` for long sync operations
|
||||||
|
|
||||||
|
### 5. Audit-log actor
|
||||||
|
- Keep as actions-only (simplified). No need to revert — it's simpler with just `append`.
|
||||||
|
|
||||||
|
### 6. Callers
|
||||||
|
|
||||||
|
**`foundry/packages/backend/src/services/better-auth.ts`:**
|
||||||
|
- Revert direct user actor action calls back to queue sends
|
||||||
|
|
||||||
|
**`foundry/packages/backend/src/actors/sandbox/index.ts`:**
|
||||||
|
- Revert `organization.getTask(...)` → queue send if it was queue-based before
|
||||||
|
- Keep the E2B timeout fix and listProcesses error handling
|
||||||
|
|
||||||
|
## Step-by-step procedure
|
||||||
|
|
||||||
|
1. Create a new branch from `task-owner-git-auth` (current HEAD)
|
||||||
|
2. For each actor, open a 3-way comparison: `main` (original queues), `queues-to-actions` (current), and your working copy
|
||||||
|
3. Restore queue/workflow run handlers and command loops from `main`
|
||||||
|
4. Restore queue name helpers and constants from `main`
|
||||||
|
5. Restore caller sites to use queue sends from `main`
|
||||||
|
6. Carefully preserve all items in the "KEEP" list above
|
||||||
|
7. Test: `cd foundry && docker compose -f compose.dev.yaml up -d`, sign in, verify GitHub sync completes, verify tasks show in sidebar, verify session creation works
|
||||||
|
8. Nuke RivetKit data between test runs: `docker volume rm foundry_foundry_rivetkit_storage`
|
||||||
|
|
||||||
|
## Verification checklist
|
||||||
|
|
||||||
|
- [ ] GitHub sync completes (160 repos for rivet-dev)
|
||||||
|
- [ ] Tasks show in sidebar (from PR sync, lazy/virtual entries)
|
||||||
|
- [ ] No task actors spawned during sync (check RivetKit inspector — should see 0 task actors until user clicks one)
|
||||||
|
- [ ] Clicking a task materializes the actor (lazy creation via getOrCreate)
|
||||||
|
- [ ] Session creation works on sandbox-agent-testing repo
|
||||||
|
- [ ] E2B sandbox provisions and connects
|
||||||
|
- [ ] Agent responds to messages
|
||||||
|
- [ ] No 500 errors in backend logs (except expected E2B sandbox expiry)
|
||||||
|
- [ ] Workflow history visible in RivetKit inspector for org, task, user actors
|
||||||
|
- [ ] CLAUDE.md constraints still documented and respected
|
||||||
94
.context/proposal-rivetkit-sandbox-resilience.md
Normal file
94
.context/proposal-rivetkit-sandbox-resilience.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# Proposal: RivetKit Sandbox Actor Resilience
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The rivetkit sandbox actor (`src/sandbox/actor.ts`) does not handle the case where the underlying cloud sandbox (e.g. E2B VM) is destroyed while the actor is still alive. This causes cascading 500 errors when the actor tries to call the dead sandbox. Additionally, a UNIQUE constraint bug in event persistence crashes the host process.
|
||||||
|
|
||||||
|
The sandbox-agent repo (which defines the E2B provider) will be updated separately to use `autoPause` and expose `pause()`/typed errors. This proposal covers the rivetkit-side changes needed to handle those signals.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. Fix `persistObservedEnvelope` UNIQUE constraint crash
|
||||||
|
|
||||||
|
**File:** `insertEvent` in the sandbox actor's SQLite persistence layer
|
||||||
|
|
||||||
|
The `sandbox_agent_events` table has a UNIQUE constraint on `(session_id, event_index)`. When the same event is observed twice (reconnection, replay, duplicate WebSocket delivery), the insert throws and crashes the host process as an unhandled rejection.
|
||||||
|
|
||||||
|
**Fix:** Change the INSERT to `INSERT OR IGNORE` / `ON CONFLICT DO NOTHING`. Duplicate events are expected and harmless — they should be silently deduplicated at the persistence layer.
|
||||||
|
|
||||||
|
### 2. Handle destroyed sandbox in `ensureAgent()`
|
||||||
|
|
||||||
|
**File:** `src/sandbox/actor.ts` — `ensureAgent()` function
|
||||||
|
|
||||||
|
When the provider's `start()` is called with an existing `sandboxId` and the sandbox no longer exists, the provider throws a typed `SandboxDestroyedError` (defined in the sandbox-agent provider contract).
|
||||||
|
|
||||||
|
`ensureAgent()` should catch this error and check the `onSandboxExpired` config option:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// New config option on sandboxActor()
|
||||||
|
onSandboxExpired?: "destroy" | "recreate"; // default: "destroy"
|
||||||
|
```
|
||||||
|
|
||||||
|
**`"destroy"` (default):**
|
||||||
|
- Set `state.sandboxDestroyed = true`
|
||||||
|
- Emit `sandboxExpired` event to all connected clients
|
||||||
|
- All subsequent action calls (runProcess, createSession, etc.) return a clear error: "Sandbox has expired. Create a new task to continue."
|
||||||
|
- The sandbox actor stays alive (preserves session history, audit log) but rejects new work
|
||||||
|
|
||||||
|
**`"recreate"`:**
|
||||||
|
- Call provider `create()` to provision a fresh sandbox
|
||||||
|
- Store new `sandboxId` in state
|
||||||
|
- Emit `sandboxRecreated` event to connected clients with a notice that sessions are lost (new VM, no prior state)
|
||||||
|
- Resume normal operation with the new sandbox
|
||||||
|
|
||||||
|
### 3. Expose `pause` action
|
||||||
|
|
||||||
|
**File:** `src/sandbox/actor.ts` — actions
|
||||||
|
|
||||||
|
Add a `pause` action that delegates to the provider's `pause()` method. This is user-initiated only (e.g. user clicks "Pause sandbox" in UI to save credits). The sandbox actor should never auto-pause.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async pause(c) {
|
||||||
|
await c.provider.pause();
|
||||||
|
state.sandboxPaused = true;
|
||||||
|
c.broadcast("sandboxPaused", {});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Expose `resume` action
|
||||||
|
|
||||||
|
**File:** `src/sandbox/actor.ts` — actions
|
||||||
|
|
||||||
|
Add a `resume` action for explicit recovery. Calls `provider.start({ sandboxId: state.sandboxId })` which auto-resumes if paused.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async resume(c) {
|
||||||
|
await ensureAgent(c); // handles reconnect internally
|
||||||
|
state.sandboxPaused = false;
|
||||||
|
c.broadcast("sandboxResumed", {});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Keep-alive while sessions are active
|
||||||
|
|
||||||
|
**File:** `src/sandbox/actor.ts`
|
||||||
|
|
||||||
|
While the sandbox actor has connected WebSocket clients, periodically extend the underlying sandbox TTL to prevent it from being garbage collected mid-session.
|
||||||
|
|
||||||
|
- On first client connect: start a keep-alive interval (e.g. every 2 minutes)
|
||||||
|
- Each tick: call `provider.extendTimeout(extensionMs)` (the provider maps this to `sandbox.setTimeout()` for E2B)
|
||||||
|
- On last client disconnect: clear the interval, let the sandbox idle toward its natural timeout
|
||||||
|
|
||||||
|
This prevents the common case where a user is actively working but the sandbox expires because the E2B default timeout (5 min) is too short. The `timeoutMs` in create options is the initial TTL; keep-alive extends it dynamically.
|
||||||
|
|
||||||
|
## Key invariant
|
||||||
|
|
||||||
|
**Never silently fail.** Every destroyed/expired/error state must be surfaced to connected clients via events. The actor must always tell the UI what happened so the user can act on it. See CLAUDE.md "never silently catch errors" rule.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
These changes depend on the sandbox-agent provider contract exposing:
|
||||||
|
- `pause()` method
|
||||||
|
- `extendTimeout(ms)` method
|
||||||
|
- Typed `SandboxDestroyedError` thrown from `start()` when sandbox is gone
|
||||||
|
- `start()` auto-resuming paused sandboxes via `Sandbox.connect(sandboxId)`
|
||||||
200
.context/proposal-task-owner-git-auth.md
Normal file
200
.context/proposal-task-owner-git-auth.md
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
# Proposal: Task Primary Owner & Git Authentication
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Sandbox git operations (commit, push, PR creation) require authentication.
|
||||||
|
Currently, the sandbox has no user-scoped credentials. The E2B sandbox
|
||||||
|
clones repos using the GitHub App installation token, but push operations
|
||||||
|
need user-scoped auth so commits are attributed correctly and branch
|
||||||
|
protection rules are enforced.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Concept: Primary User per Task
|
||||||
|
|
||||||
|
Each task has a **primary user** (the "owner"). This is the last user who
|
||||||
|
sent a message on the task. Their GitHub OAuth credentials are injected
|
||||||
|
into the sandbox for git operations. When the owner changes, the sandbox
|
||||||
|
git config and credentials swap to the new user.
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
**Task actor DB** -- new `task_owner` single-row table:
|
||||||
|
- `primaryUserId` (text) -- better-auth user ID
|
||||||
|
- `primaryGithubLogin` (text) -- GitHub username (for `git config user.name`)
|
||||||
|
- `primaryGithubEmail` (text) -- GitHub email (for `git config user.email`)
|
||||||
|
- `primaryGithubAvatarUrl` (text) -- avatar for UI display
|
||||||
|
- `updatedAt` (integer)
|
||||||
|
|
||||||
|
**Org coordinator** -- add to `taskSummaries` table:
|
||||||
|
- `primaryUserLogin` (text, nullable)
|
||||||
|
- `primaryUserAvatarUrl` (text, nullable)
|
||||||
|
|
||||||
|
### Owner Swap Flow
|
||||||
|
|
||||||
|
Triggered when `sendWorkspaceMessage` is called with a different user than
|
||||||
|
the current primary:
|
||||||
|
|
||||||
|
1. `sendWorkspaceMessage(authSessionId, ...)` resolves user from auth session
|
||||||
|
2. Look up user's GitHub identity from auth account table (`providerId = "github"`)
|
||||||
|
3. Compare `primaryUserId` with current owner. If different:
|
||||||
|
a. Update `task_owner` row in task actor DB
|
||||||
|
b. Get user's OAuth `accessToken` from auth account
|
||||||
|
c. Push into sandbox via `runProcess`:
|
||||||
|
- `git config user.name "{login}"`
|
||||||
|
- `git config user.email "{email}"`
|
||||||
|
- Write token to `/home/user/.git-token` (or equivalent)
|
||||||
|
d. Push updated task summary to org coordinator (includes `primaryUserLogin`)
|
||||||
|
e. Broadcast `taskUpdated` to connected clients
|
||||||
|
4. If same user, no-op (token is still valid)
|
||||||
|
|
||||||
|
### Token Injection
|
||||||
|
|
||||||
|
The user's GitHub OAuth token (stored in better-auth account table) has
|
||||||
|
`repo` scope (verified -- see `better-auth.ts` line 480: `scope: ["read:org", "repo"]`).
|
||||||
|
|
||||||
|
This is a standard **OAuth App** flow (not GitHub App OAuth). OAuth App
|
||||||
|
tokens do not expire unless explicitly revoked. No refresh logic is needed.
|
||||||
|
|
||||||
|
**Injection method:**
|
||||||
|
|
||||||
|
On first sandbox repo setup (`ensureSandboxRepo`), configure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Write token file
|
||||||
|
echo "{token}" > /home/user/.git-token
|
||||||
|
chmod 600 /home/user/.git-token
|
||||||
|
|
||||||
|
# Configure git to use it
|
||||||
|
git config --global credential.helper 'store --file=/home/user/.git-token'
|
||||||
|
|
||||||
|
# Format: https://{login}:{token}@github.com
|
||||||
|
echo "https://{login}:{token}@github.com" > /home/user/.git-token
|
||||||
|
```
|
||||||
|
|
||||||
|
On owner swap, overwrite `/home/user/.git-token` with new user's credentials.
|
||||||
|
|
||||||
|
**Important: git should never prompt for credentials.** The credential
|
||||||
|
store file ensures all git operations are auto-authenticated. No
|
||||||
|
`GIT_ASKPASS` prompts, no interactive auth.
|
||||||
|
|
||||||
|
**Race condition (expected behavior):** If User A sends a message and the
|
||||||
|
agent starts a long git operation, then User B sends a message and 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. This is expected behavior -- document in comments.
|
||||||
|
|
||||||
|
### Token Validity
|
||||||
|
|
||||||
|
OAuth App tokens (our flow) do not expire. They persist until the user
|
||||||
|
revokes them or the OAuth App is deauthorized. No periodic refresh needed.
|
||||||
|
|
||||||
|
If a token becomes invalid (user revokes), git operations will fail with
|
||||||
|
a 401. The error surfaces through the standard `ensureSandboxRepo` /
|
||||||
|
`runProcess` error path and is displayed in the UI.
|
||||||
|
|
||||||
|
### User Removal
|
||||||
|
|
||||||
|
When a user is removed from the organization:
|
||||||
|
1. Org actor queries active tasks with that user as primary owner
|
||||||
|
2. For each, clear the `task_owner` row
|
||||||
|
3. Task actor clears the sandbox git credentials (overwrite credential file)
|
||||||
|
4. Push updated task summaries to org coordinator
|
||||||
|
5. Subsequent git operations fail with "No active owner -- assign an owner to enable git operations"
|
||||||
|
|
||||||
|
### UI Changes
|
||||||
|
|
||||||
|
**Right sidebar -- new "Overview" tab:**
|
||||||
|
- Add as a new tab alongside "Changes" and "All Files"
|
||||||
|
- Shows current primary user: avatar, name, login
|
||||||
|
- Click on the user -> dropdown of all workspace users (from org member list)
|
||||||
|
- Select a user -> triggers explicit owner swap (same flow as message-triggered)
|
||||||
|
- Also shows task metadata: branch, repo, created date
|
||||||
|
|
||||||
|
**Left sidebar -- task items:**
|
||||||
|
- Show primary user's GitHub login in green text next to task name
|
||||||
|
- Only shown when there is an active owner
|
||||||
|
|
||||||
|
**Task detail header:**
|
||||||
|
- Show small avatar of primary user next to task title
|
||||||
|
|
||||||
|
### Org Coordinator
|
||||||
|
|
||||||
|
`commandApplyTaskSummaryUpdate` already receives the full task summary
|
||||||
|
from the task actor. Add `primaryUserLogin` and `primaryUserAvatarUrl`
|
||||||
|
to the summary payload. The org writes it to `taskSummaries`. The sidebar
|
||||||
|
reads it from the org snapshot.
|
||||||
|
|
||||||
|
### Sandbox Architecture Note
|
||||||
|
|
||||||
|
Structurally, the system supports multiple sandboxes per task, but in
|
||||||
|
practice there is exactly one active sandbox per task. Design the owner
|
||||||
|
injection assuming one sandbox. The token is injected into the active
|
||||||
|
sandbox only. If multi-sandbox support is needed in the future, extend
|
||||||
|
the injection to target specific sandbox IDs.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### OAuth Token Scope
|
||||||
|
|
||||||
|
The user's GitHub OAuth token has `repo` scope, which grants **full control
|
||||||
|
of all private repositories** the user has access to. When injected into
|
||||||
|
the sandbox:
|
||||||
|
|
||||||
|
- The agent can read/write ANY repo the user has access to, not just the
|
||||||
|
task's target repo
|
||||||
|
- The token persists in the sandbox filesystem until overwritten
|
||||||
|
- Any process running in the sandbox can read the credential file
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- Credential file has `chmod 600` (owner-read-only)
|
||||||
|
- Sandbox is isolated per-task (E2B VM boundary)
|
||||||
|
- Token is overwritten on owner swap (old user's token removed)
|
||||||
|
- Token is cleared on user removal from org
|
||||||
|
- Sandbox has a finite lifetime (E2B timeout + autoPause)
|
||||||
|
|
||||||
|
**Accepted risk:** This is the standard trade-off for OAuth-based git
|
||||||
|
integrations (same as GitHub Codespaces, Gitpod, etc.). The user consents
|
||||||
|
to `repo` scope at sign-in time. Document this in user-facing terms in
|
||||||
|
the product's security/privacy page.
|
||||||
|
|
||||||
|
### Future: Fine-grained tokens
|
||||||
|
|
||||||
|
GitHub supports fine-grained personal access tokens scoped to specific
|
||||||
|
repos. A future improvement could mint per-repo tokens instead of using
|
||||||
|
the user's full OAuth token. This requires the user to create and manage
|
||||||
|
fine-grained tokens, which adds friction. Evaluate based on user feedback.
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add `task_owner` table to task actor schema + migration
|
||||||
|
2. Add `primaryUserLogin` / `primaryUserAvatarUrl` to `taskSummaries` schema + migration
|
||||||
|
3. Implement owner swap in `sendWorkspaceMessage` flow
|
||||||
|
4. Implement credential injection in `ensureSandboxRepo`
|
||||||
|
5. Implement credential swap via `runProcess` on owner change
|
||||||
|
6. Implement user removal cleanup in org actor
|
||||||
|
7. Add "Overview" tab to right sidebar
|
||||||
|
8. Add owner display to left sidebar task items
|
||||||
|
9. Add owner picker dropdown in Overview tab
|
||||||
|
10. Update org coordinator to propagate owner in task summaries
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `foundry/packages/backend/src/actors/task/db/schema.ts` -- add `task_owner` table
|
||||||
|
- `foundry/packages/backend/src/actors/task/db/migrations.ts` -- add migration
|
||||||
|
- `foundry/packages/backend/src/actors/organization/db/schema.ts` -- add owner columns to `taskSummaries`
|
||||||
|
- `foundry/packages/backend/src/actors/organization/db/migrations.ts` -- add migration
|
||||||
|
- `foundry/packages/backend/src/actors/task/workspace.ts` -- owner swap logic in `sendWorkspaceMessage`, credential injection in `ensureSandboxRepo`
|
||||||
|
- `foundry/packages/backend/src/actors/task/workflow/index.ts` -- wire owner swap action
|
||||||
|
- `foundry/packages/backend/src/actors/organization/actions/task-mutations.ts` -- propagate owner in summaries
|
||||||
|
- `foundry/packages/backend/src/actors/organization/actions/tasks.ts` -- `sendWorkspaceMessage` owner check
|
||||||
|
- `foundry/packages/backend/src/services/better-auth.ts` -- expose `getAccessTokenForSession` for owner lookup
|
||||||
|
|
||||||
|
### Shared
|
||||||
|
- `foundry/packages/shared/src/types.ts` -- add `primaryUserLogin` to `TaskSummary`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx` -- add Overview tab
|
||||||
|
- `foundry/packages/frontend/src/components/organization-dashboard.tsx` -- show owner in sidebar task items
|
||||||
|
- `foundry/packages/frontend/src/components/mock-layout.tsx` -- wire Overview tab state
|
||||||
|
|
@ -136,6 +136,14 @@ The client subscribes to `app` always, `organization` when entering an organizat
|
||||||
- Backend mutations that affect sidebar data (task title, status, branch, PR state) must push the updated summary to the parent organization actor, which broadcasts to organization subscribers.
|
- Backend mutations that affect sidebar data (task title, status, branch, PR state) must push the updated summary to the parent organization actor, which broadcasts to organization subscribers.
|
||||||
- Comment architecture-related code: add doc comments explaining the materialized state pattern, why deltas flow the way they do, and the relationship between parent/child actor broadcasts. New contributors should understand the data flow from comments alone.
|
- Comment architecture-related code: add doc comments explaining the materialized state pattern, why deltas flow the way they do, and the relationship between parent/child actor broadcasts. New contributors should understand the data flow from comments alone.
|
||||||
|
|
||||||
|
## Sandbox Architecture
|
||||||
|
|
||||||
|
- Structurally, the system supports multiple sandboxes per task, but in practice there is exactly one active sandbox per task. Design features assuming one sandbox per task. If multi-sandbox is needed in the future, extend at that time.
|
||||||
|
- Each task has a **primary user** (owner) whose GitHub OAuth credentials are injected into the sandbox for git operations. The owner swaps when a different user sends a message. See `.context/proposal-task-owner-git-auth.md` for the full design.
|
||||||
|
- **Security: OAuth token scope.** The user's GitHub OAuth token has `repo` scope, granting full control of all private repositories the user has access to. When the user is the active task owner, their token is injected into the sandbox. This means the agent can read/write ANY repo the user has access to, not just the task's target repo. This is the standard trade-off for OAuth-based git integrations (same as GitHub Codespaces, Gitpod). The user consents to `repo` scope at sign-in time. Credential files in the sandbox are `chmod 600` and overwritten on owner swap.
|
||||||
|
- All git operations in the sandbox must be auto-authenticated. Never configure git to prompt for credentials (no interactive `GIT_ASKPASS` prompts). Use a credential store file that is pre-populated with the active owner's token.
|
||||||
|
- All git operation errors (push 401, clone failure, branch protection rejection) must surface in the UI with actionable context. Never silently swallow git errors.
|
||||||
|
|
||||||
## Git State Policy
|
## Git State Policy
|
||||||
|
|
||||||
- The backend stores zero git state. No local clones, no refs, no working trees, and no git-spice.
|
- The backend stores zero git state. No local clones, no refs, no working trees, and no git-spice.
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ function taskSummaryRowFromSummary(taskSummary: WorkspaceTaskSummary) {
|
||||||
branch: taskSummary.branch,
|
branch: taskSummary.branch,
|
||||||
pullRequestJson: JSON.stringify(taskSummary.pullRequest),
|
pullRequestJson: JSON.stringify(taskSummary.pullRequest),
|
||||||
sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary),
|
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,
|
branch: row.branch ?? null,
|
||||||
pullRequest: parseJsonValue<WorkspacePullRequestSummary | null>(row.pullRequestJson, null),
|
pullRequest: parseJsonValue<WorkspacePullRequestSummary | null>(row.pullRequestJson, null),
|
||||||
sessionsSummary: parseJsonValue<WorkspaceSessionSummary[]>(row.sessionsSummaryJson, []),
|
sessionsSummary: parseJsonValue<WorkspaceSessionSummary[]>(row.sessionsSummaryJson, []),
|
||||||
|
primaryUserLogin: row.primaryUserLogin ?? null,
|
||||||
|
primaryUserAvatarUrl: row.primaryUserAvatarUrl ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
TaskRecord,
|
TaskRecord,
|
||||||
TaskSummary,
|
TaskSummary,
|
||||||
TaskWorkspaceChangeModelInput,
|
TaskWorkspaceChangeModelInput,
|
||||||
|
TaskWorkspaceChangeOwnerInput,
|
||||||
TaskWorkspaceCreateTaskInput,
|
TaskWorkspaceCreateTaskInput,
|
||||||
TaskWorkspaceDiffInput,
|
TaskWorkspaceDiffInput,
|
||||||
TaskWorkspaceRenameInput,
|
TaskWorkspaceRenameInput,
|
||||||
|
|
@ -217,6 +218,16 @@ export const organizationTaskActions = {
|
||||||
void task.publishPr({}).catch(() => {});
|
void task.publishPr({}).catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async changeWorkspaceTaskOwner(c: any, input: TaskWorkspaceChangeOwnerInput): Promise<void> {
|
||||||
|
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<void> {
|
async revertWorkspaceFile(c: any, input: TaskWorkspaceDiffInput): Promise<void> {
|
||||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||||
void task.revertFile(input).catch(() => {});
|
void task.revertFile(input).catch(() => {});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ const journal = {
|
||||||
tag: "0001_add_auth_and_task_tables",
|
tag: "0001_add_auth_and_task_tables",
|
||||||
breakpoints: true,
|
breakpoints: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
idx: 2,
|
||||||
|
when: 1773984000000,
|
||||||
|
tag: "0002_add_task_owner_columns",
|
||||||
|
breakpoints: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -165,6 +171,10 @@ CREATE TABLE \`task_summaries\` (
|
||||||
\`pull_request_json\` text,
|
\`pull_request_json\` text,
|
||||||
\`sessions_summary_json\` text DEFAULT '[]' NOT NULL
|
\`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,
|
} as const,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ export const taskSummaries = sqliteTable("task_summaries", {
|
||||||
branch: text("branch"),
|
branch: text("branch"),
|
||||||
pullRequestJson: text("pull_request_json"),
|
pullRequestJson: text("pull_request_json"),
|
||||||
sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"),
|
sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"),
|
||||||
|
primaryUserLogin: text("primary_user_login"),
|
||||||
|
primaryUserAvatarUrl: text("primary_user_avatar_url"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const organizationProfile = sqliteTable(
|
export const organizationProfile = sqliteTable(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,12 @@ const journal = {
|
||||||
tag: "0000_charming_maestro",
|
tag: "0000_charming_maestro",
|
||||||
breakpoints: true,
|
breakpoints: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
idx: 1,
|
||||||
|
when: 1773984000000,
|
||||||
|
tag: "0001_add_task_owner",
|
||||||
|
breakpoints: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -65,6 +71,16 @@ CREATE TABLE \`task_workspace_sessions\` (
|
||||||
\`created_at\` integer NOT NULL,
|
\`created_at\` integer NOT NULL,
|
||||||
\`updated_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,
|
} as const,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,24 @@ export const taskSandboxes = sqliteTable("task_sandboxes", {
|
||||||
updatedAt: integer("updated_at").notNull(),
|
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.
|
* Coordinator index of workspace sessions within this task.
|
||||||
* The task actor is the coordinator for sessions. Each row holds session
|
* The task actor is the coordinator for sessions. Each row holds session
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
killWriteDbActivity,
|
killWriteDbActivity,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
import {
|
import {
|
||||||
|
changeTaskOwnerManually,
|
||||||
changeWorkspaceModel,
|
changeWorkspaceModel,
|
||||||
closeWorkspaceSession,
|
closeWorkspaceSession,
|
||||||
createWorkspaceSession,
|
createWorkspaceSession,
|
||||||
|
|
@ -176,6 +177,16 @@ export const taskCommandActions = {
|
||||||
return { ok: true };
|
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) {
|
async createSession(c: any, body: any) {
|
||||||
return await createWorkspaceSession(c, body?.model, body?.authSessionId);
|
return await createWorkspaceSession(c, body?.model, body?.authSessionId);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
|
||||||
import { githubRepoFullNameFromRemote } from "../../services/repo.js";
|
import { githubRepoFullNameFromRemote } from "../../services/repo.js";
|
||||||
// organization actions called directly (no queue)
|
// 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";
|
import { getCurrentRecord } from "./workflow/common.js";
|
||||||
|
|
||||||
function emptyGitState() {
|
function emptyGitState() {
|
||||||
|
|
@ -123,6 +123,193 @@ function parseGitState(value: string | null | undefined): { fileChanges: Array<a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readTaskOwner(
|
||||||
|
c: any,
|
||||||
|
): Promise<{
|
||||||
|
primaryUserId: string | null;
|
||||||
|
primaryGithubLogin: string | null;
|
||||||
|
primaryGithubEmail: string | null;
|
||||||
|
primaryGithubAvatarUrl: string | null;
|
||||||
|
} | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
await upsertTaskOwner(c, input);
|
||||||
|
await broadcastTaskUpdate(c);
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean {
|
export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean {
|
||||||
if (status === "running") {
|
if (status === "running") {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -443,7 +630,7 @@ async function getTaskSandboxRuntime(
|
||||||
*/
|
*/
|
||||||
let sandboxRepoPrepared = false;
|
let sandboxRepoPrepared = false;
|
||||||
|
|
||||||
async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { skipFetchIfPrepared?: boolean }): Promise<void> {
|
async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { skipFetchIfPrepared?: boolean; authSessionId?: string | null }): Promise<void> {
|
||||||
if (!record.branchName) {
|
if (!record.branchName) {
|
||||||
throw new Error("cannot prepare a sandbox repo before the task branch exists");
|
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("")}`);
|
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;
|
sandboxRepoPrepared = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -862,6 +1055,8 @@ export async function buildTaskSummary(c: any, authSessionId?: string | null): P
|
||||||
const activeSessionId =
|
const activeSessionId =
|
||||||
userTaskState.activeSessionId && sessions.some((meta) => meta.sessionId === userTaskState.activeSessionId) ? userTaskState.activeSessionId : null;
|
userTaskState.activeSessionId && sessions.some((meta) => meta.sessionId === userTaskState.activeSessionId) ? userTaskState.activeSessionId : null;
|
||||||
|
|
||||||
|
const owner = await readTaskOwner(c);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: c.state.taskId,
|
id: c.state.taskId,
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
|
|
@ -873,6 +1068,8 @@ export async function buildTaskSummary(c: any, authSessionId?: string | null): P
|
||||||
pullRequest: record.pullRequest ?? null,
|
pullRequest: record.pullRequest ?? null,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
sessionsSummary: sessions.map((meta) => buildSessionSummary(meta, userTaskState.bySessionId.get(meta.sessionId))),
|
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);
|
const runtime = await getTaskSandboxRuntime(c, record);
|
||||||
// Skip git fetch on subsequent messages — the repo was already prepared during session
|
// 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.
|
// 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(
|
const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)].filter(
|
||||||
Boolean,
|
Boolean,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
TaskRecord,
|
TaskRecord,
|
||||||
TaskSummary,
|
TaskSummary,
|
||||||
TaskWorkspaceChangeModelInput,
|
TaskWorkspaceChangeModelInput,
|
||||||
|
TaskWorkspaceChangeOwnerInput,
|
||||||
TaskWorkspaceCreateTaskInput,
|
TaskWorkspaceCreateTaskInput,
|
||||||
TaskWorkspaceCreateTaskResponse,
|
TaskWorkspaceCreateTaskResponse,
|
||||||
TaskWorkspaceDiffInput,
|
TaskWorkspaceDiffInput,
|
||||||
|
|
@ -110,6 +111,7 @@ interface OrganizationHandle {
|
||||||
stopWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
|
stopWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
|
||||||
closeWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
|
closeWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
|
||||||
publishWorkspacePr(input: TaskWorkspaceSelectInput & AuthSessionScopedInput): Promise<void>;
|
publishWorkspacePr(input: TaskWorkspaceSelectInput & AuthSessionScopedInput): Promise<void>;
|
||||||
|
changeWorkspaceTaskOwner(input: TaskWorkspaceChangeOwnerInput & AuthSessionScopedInput): Promise<void>;
|
||||||
revertWorkspaceFile(input: TaskWorkspaceDiffInput & AuthSessionScopedInput): Promise<void>;
|
revertWorkspaceFile(input: TaskWorkspaceDiffInput & AuthSessionScopedInput): Promise<void>;
|
||||||
adminReloadGithubOrganization(): Promise<void>;
|
adminReloadGithubOrganization(): Promise<void>;
|
||||||
adminReloadGithubRepository(input: { repoId: string }): Promise<void>;
|
adminReloadGithubRepository(input: { repoId: string }): Promise<void>;
|
||||||
|
|
@ -304,6 +306,7 @@ export interface BackendClient {
|
||||||
stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void>;
|
stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void>;
|
||||||
closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void>;
|
closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void>;
|
||||||
publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void>;
|
publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void>;
|
||||||
|
changeWorkspaceTaskOwner(organizationId: string, input: TaskWorkspaceChangeOwnerInput): Promise<void>;
|
||||||
revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void>;
|
revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void>;
|
||||||
adminReloadGithubOrganization(organizationId: string): Promise<void>;
|
adminReloadGithubOrganization(organizationId: string): Promise<void>;
|
||||||
adminReloadGithubRepository(organizationId: string, repoId: string): Promise<void>;
|
adminReloadGithubRepository(organizationId: string, repoId: string): Promise<void>;
|
||||||
|
|
@ -1282,6 +1285,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
||||||
await (await organization(organizationId)).publishWorkspacePr(await withAuthSessionInput(input));
|
await (await organization(organizationId)).publishWorkspacePr(await withAuthSessionInput(input));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async changeWorkspaceTaskOwner(organizationId: string, input: TaskWorkspaceChangeOwnerInput): Promise<void> {
|
||||||
|
await (await organization(organizationId)).changeWorkspaceTaskOwner(await withAuthSessionInput(input));
|
||||||
|
},
|
||||||
|
|
||||||
async revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void> {
|
async revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void> {
|
||||||
await (await organization(organizationId)).revertWorkspaceFile(await withAuthSessionInput(input));
|
await (await organization(organizationId)).revertWorkspaceFile(await withAuthSessionInput(input));
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,8 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
|
||||||
unread: tab.unread,
|
unread: tab.unread,
|
||||||
created: tab.created,
|
created: tab.created,
|
||||||
})),
|
})),
|
||||||
|
primaryUserLogin: null,
|
||||||
|
primaryUserAvatarUrl: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildTaskDetail = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskDetail => ({
|
const buildTaskDetail = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskDetail => ({
|
||||||
|
|
@ -750,6 +752,15 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
|
||||||
emitTaskUpdate(input.taskId);
|
emitTaskUpdate(input.taskId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async changeWorkspaceTaskOwner(
|
||||||
|
_organizationId: string,
|
||||||
|
input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await workspace.changeOwner(input);
|
||||||
|
emitOrganizationSnapshot();
|
||||||
|
emitTaskUpdate(input.taskId);
|
||||||
|
},
|
||||||
|
|
||||||
async revertWorkspaceFile(_organizationId: string, input: TaskWorkspaceDiffInput): Promise<void> {
|
async revertWorkspaceFile(_organizationId: string, input: TaskWorkspaceDiffInput): Promise<void> {
|
||||||
await workspace.revertFile(input);
|
await workspace.revertFile(input);
|
||||||
emitOrganizationSnapshot();
|
emitOrganizationSnapshot();
|
||||||
|
|
|
||||||
|
|
@ -349,7 +349,10 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...currentTask,
|
...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),
|
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<void> {
|
||||||
|
this.updateTask(input.taskId, (currentTask) => ({
|
||||||
|
...currentTask,
|
||||||
|
primaryUserLogin: input.targetUserName,
|
||||||
|
primaryUserAvatarUrl: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private updateState(updater: (current: TaskWorkspaceSnapshot) => TaskWorkspaceSnapshot): void {
|
private updateState(updater: (current: TaskWorkspaceSnapshot) => TaskWorkspaceSnapshot): void {
|
||||||
const nextSnapshot = updater(this.snapshot);
|
const nextSnapshot = updater(this.snapshot);
|
||||||
this.snapshot = {
|
this.snapshot = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
TaskWorkspaceAddSessionResponse,
|
TaskWorkspaceAddSessionResponse,
|
||||||
TaskWorkspaceChangeModelInput,
|
TaskWorkspaceChangeModelInput,
|
||||||
|
TaskWorkspaceChangeOwnerInput,
|
||||||
TaskWorkspaceCreateTaskInput,
|
TaskWorkspaceCreateTaskInput,
|
||||||
TaskWorkspaceCreateTaskResponse,
|
TaskWorkspaceCreateTaskResponse,
|
||||||
TaskWorkspaceDiffInput,
|
TaskWorkspaceDiffInput,
|
||||||
|
|
@ -140,6 +141,11 @@ class RemoteWorkspaceStore implements TaskWorkspaceClient {
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async changeOwner(input: TaskWorkspaceChangeOwnerInput): Promise<void> {
|
||||||
|
await this.backend.changeWorkspaceTaskOwner(this.organizationId, input);
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
private ensureStarted(): void {
|
private ensureStarted(): void {
|
||||||
if (!this.unsubscribeWorkspace) {
|
if (!this.unsubscribeWorkspace) {
|
||||||
this.unsubscribeWorkspace = this.backend.subscribeWorkspace(this.organizationId, () => {
|
this.unsubscribeWorkspace = this.backend.subscribeWorkspace(this.organizationId, () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
TaskWorkspaceAddSessionResponse,
|
TaskWorkspaceAddSessionResponse,
|
||||||
TaskWorkspaceChangeModelInput,
|
TaskWorkspaceChangeModelInput,
|
||||||
|
TaskWorkspaceChangeOwnerInput,
|
||||||
TaskWorkspaceCreateTaskInput,
|
TaskWorkspaceCreateTaskInput,
|
||||||
TaskWorkspaceCreateTaskResponse,
|
TaskWorkspaceCreateTaskResponse,
|
||||||
TaskWorkspaceDiffInput,
|
TaskWorkspaceDiffInput,
|
||||||
|
|
@ -43,6 +44,7 @@ export interface TaskWorkspaceClient {
|
||||||
closeSession(input: TaskWorkspaceSessionInput): Promise<void>;
|
closeSession(input: TaskWorkspaceSessionInput): Promise<void>;
|
||||||
addSession(input: TaskWorkspaceSelectInput): Promise<TaskWorkspaceAddSessionResponse>;
|
addSession(input: TaskWorkspaceSelectInput): Promise<TaskWorkspaceAddSessionResponse>;
|
||||||
changeModel(input: TaskWorkspaceChangeModelInput): Promise<void>;
|
changeModel(input: TaskWorkspaceChangeModelInput): Promise<void>;
|
||||||
|
changeOwner(input: TaskWorkspaceChangeOwnerInput): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTaskWorkspaceClient(options: CreateTaskWorkspaceClientOptions): TaskWorkspaceClient {
|
export function createTaskWorkspaceClient(options: CreateTaskWorkspaceClientOptions): TaskWorkspaceClient {
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,8 @@ function organizationSnapshot(): OrganizationSummarySnapshot {
|
||||||
pullRequest: null,
|
pullRequest: null,
|
||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
sessionsSummary: [],
|
sessionsSummary: [],
|
||||||
|
primaryUserLogin: null,
|
||||||
|
primaryUserAvatarUrl: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
@ -159,6 +161,8 @@ describe("RemoteSubscriptionManager", () => {
|
||||||
pullRequest: null,
|
pullRequest: null,
|
||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
sessionsSummary: [],
|
sessionsSummary: [],
|
||||||
|
primaryUserLogin: null,
|
||||||
|
primaryUserAvatarUrl: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ import {
|
||||||
type Message,
|
type Message,
|
||||||
type ModelId,
|
type ModelId,
|
||||||
} from "./mock-layout/view-model";
|
} 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 { backendClient } from "../lib/backend";
|
||||||
import { subscriptionManager } from "../lib/subscription";
|
import { subscriptionManager } from "../lib/subscription";
|
||||||
import { describeTaskState, isProvisioningTaskStatus } from "../features/tasks/status";
|
import { describeTaskState, isProvisioningTaskStatus } from "../features/tasks/status";
|
||||||
|
|
@ -188,6 +188,8 @@ function toTaskModel(
|
||||||
fileTree: detail?.fileTree ?? [],
|
fileTree: detail?.fileTree ?? [],
|
||||||
minutesUsed: detail?.minutesUsed ?? 0,
|
minutesUsed: detail?.minutesUsed ?? 0,
|
||||||
activeSandboxId: detail?.activeSandboxId ?? null,
|
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<void>;
|
closeSession(input: { repoId: string; taskId: string; sessionId: string }): Promise<void>;
|
||||||
addSession(input: { repoId: string; taskId: string; model?: string }): Promise<{ sessionId: string }>;
|
addSession(input: { repoId: string; taskId: string; model?: string }): Promise<{ sessionId: string }>;
|
||||||
changeModel(input: { repoId: string; taskId: string; sessionId: string; model: ModelId }): Promise<void>;
|
changeModel(input: { repoId: string; taskId: string; sessionId: string; model: ModelId }): Promise<void>;
|
||||||
|
changeOwner(input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }): Promise<void>;
|
||||||
adminReloadGithubOrganization(): Promise<void>;
|
adminReloadGithubOrganization(): Promise<void>;
|
||||||
adminReloadGithubRepository(repoId: string): Promise<void>;
|
adminReloadGithubRepository(repoId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
@ -1069,6 +1072,8 @@ const RightRail = memo(function RightRail({
|
||||||
onArchive,
|
onArchive,
|
||||||
onRevertFile,
|
onRevertFile,
|
||||||
onPublishPr,
|
onPublishPr,
|
||||||
|
onChangeOwner,
|
||||||
|
members,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
}: {
|
}: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
|
@ -1078,6 +1083,8 @@ const RightRail = memo(function RightRail({
|
||||||
onArchive: () => void;
|
onArchive: () => void;
|
||||||
onRevertFile: (path: string) => void;
|
onRevertFile: (path: string) => void;
|
||||||
onPublishPr: () => void;
|
onPublishPr: () => void;
|
||||||
|
onChangeOwner: (member: { id: string; name: string; email: string }) => void;
|
||||||
|
members: Array<{ id: string; name: string; email: string }>;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [css] = useStyletron();
|
const [css] = useStyletron();
|
||||||
|
|
@ -1170,6 +1177,8 @@ const RightRail = memo(function RightRail({
|
||||||
onArchive={onArchive}
|
onArchive={onArchive}
|
||||||
onRevertFile={onRevertFile}
|
onRevertFile={onRevertFile}
|
||||||
onPublishPr={onPublishPr}
|
onPublishPr={onPublishPr}
|
||||||
|
onChangeOwner={onChangeOwner}
|
||||||
|
members={members}
|
||||||
onToggleSidebar={onToggleSidebar}
|
onToggleSidebar={onToggleSidebar}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1311,6 +1320,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
|
||||||
closeSession: (input) => backendClient.closeWorkspaceSession(organizationId, input),
|
closeSession: (input) => backendClient.closeWorkspaceSession(organizationId, input),
|
||||||
addSession: (input) => backendClient.createWorkspaceSession(organizationId, input),
|
addSession: (input) => backendClient.createWorkspaceSession(organizationId, input),
|
||||||
changeModel: (input) => backendClient.changeWorkspaceModel(organizationId, input),
|
changeModel: (input) => backendClient.changeWorkspaceModel(organizationId, input),
|
||||||
|
changeOwner: (input) => backendClient.changeWorkspaceTaskOwner(organizationId, input),
|
||||||
adminReloadGithubOrganization: () => backendClient.adminReloadGithubOrganization(organizationId),
|
adminReloadGithubOrganization: () => backendClient.adminReloadGithubOrganization(organizationId),
|
||||||
adminReloadGithubRepository: (repoId) => backendClient.adminReloadGithubRepository(organizationId, repoId),
|
adminReloadGithubRepository: (repoId) => backendClient.adminReloadGithubRepository(organizationId, repoId),
|
||||||
}),
|
}),
|
||||||
|
|
@ -1741,6 +1751,22 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
|
||||||
[tasks],
|
[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(() => {
|
const archiveTask = useCallback(() => {
|
||||||
if (!activeTask) {
|
if (!activeTask) {
|
||||||
throw new Error("Cannot archive without an active task");
|
throw new Error("Cannot archive without an active task");
|
||||||
|
|
@ -2167,6 +2193,8 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
|
||||||
onArchive={archiveTask}
|
onArchive={archiveTask}
|
||||||
onRevertFile={revertFile}
|
onRevertFile={revertFile}
|
||||||
onPublishPr={publishPr}
|
onPublishPr={publishPr}
|
||||||
|
onChangeOwner={changeOwner}
|
||||||
|
members={getMockOrganizationById(appSnapshot, organizationId)?.members ?? []}
|
||||||
onToggleSidebar={() => setRightSidebarOpen(false)}
|
onToggleSidebar={() => setRightSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 { useStyletron } from "baseui";
|
||||||
import { LabelSmall } from "baseui/typography";
|
import { LabelSmall, LabelXSmall } from "baseui/typography";
|
||||||
import { Archive, ArrowUpFromLine, ChevronRight, FileCode, FilePlus, FileX, FolderOpen, GitPullRequest, PanelRight } from "lucide-react";
|
import {
|
||||||
|
Archive,
|
||||||
|
ArrowUpFromLine,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FileCode,
|
||||||
|
FilePlus,
|
||||||
|
FileX,
|
||||||
|
FolderOpen,
|
||||||
|
GitBranch,
|
||||||
|
GitPullRequest,
|
||||||
|
PanelRight,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useFoundryTokens } from "../../app/theme";
|
import { useFoundryTokens } from "../../app/theme";
|
||||||
import { createErrorContext } from "@sandbox-agent/foundry-shared";
|
import { createErrorContext } from "@sandbox-agent/foundry-shared";
|
||||||
|
|
@ -99,6 +112,8 @@ export const RightSidebar = memo(function RightSidebar({
|
||||||
onArchive,
|
onArchive,
|
||||||
onRevertFile,
|
onRevertFile,
|
||||||
onPublishPr,
|
onPublishPr,
|
||||||
|
onChangeOwner,
|
||||||
|
members,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
|
|
@ -107,11 +122,13 @@ export const RightSidebar = memo(function RightSidebar({
|
||||||
onArchive: () => void;
|
onArchive: () => void;
|
||||||
onRevertFile: (path: string) => void;
|
onRevertFile: (path: string) => void;
|
||||||
onPublishPr: () => void;
|
onPublishPr: () => void;
|
||||||
|
onChangeOwner: (member: { id: string; name: string; email: string }) => void;
|
||||||
|
members: Array<{ id: string; name: string; email: string }>;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [css] = useStyletron();
|
const [css] = useStyletron();
|
||||||
const t = useFoundryTokens();
|
const t = useFoundryTokens();
|
||||||
const [rightTab, setRightTab] = useState<"changes" | "files">("changes");
|
const [rightTab, setRightTab] = useState<"overview" | "changes" | "files">("changes");
|
||||||
const contextMenu = useContextMenu();
|
const contextMenu = useContextMenu();
|
||||||
const changedPaths = useMemo(() => new Set(task.fileChanges.map((file) => file.path)), [task.fileChanges]);
|
const changedPaths = useMemo(() => new Set(task.fileChanges.map((file) => file.path)), [task.fileChanges]);
|
||||||
const isTerminal = task.status === "archived";
|
const isTerminal = task.status === "archived";
|
||||||
|
|
@ -125,6 +142,8 @@ export const RightSidebar = memo(function RightSidebar({
|
||||||
});
|
});
|
||||||
observer.observe(node);
|
observer.observe(node);
|
||||||
}, []);
|
}, []);
|
||||||
|
const [ownerDropdownOpen, setOwnerDropdownOpen] = useState(false);
|
||||||
|
const ownerDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const pullRequestUrl = task.pullRequest?.url ?? null;
|
const pullRequestUrl = task.pullRequest?.url ?? null;
|
||||||
|
|
||||||
const copyFilePath = useCallback(async (path: string) => {
|
const copyFilePath = useCallback(async (path: string) => {
|
||||||
|
|
@ -310,7 +329,7 @@ export const RightSidebar = memo(function RightSidebar({
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setRightTab("changes")}
|
onClick={() => setRightTab("overview")}
|
||||||
className={css({
|
className={css({
|
||||||
appearance: "none",
|
appearance: "none",
|
||||||
WebkitAppearance: "none",
|
WebkitAppearance: "none",
|
||||||
|
|
@ -322,6 +341,36 @@ export const RightSidebar = memo(function RightSidebar({
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
padding: "4px 12px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
color: rightTab === "overview" ? t.textPrimary : t.textSecondary,
|
||||||
|
backgroundColor: rightTab === "overview" ? t.interactiveHover : "transparent",
|
||||||
|
transitionProperty: "color, background-color",
|
||||||
|
transitionDuration: "200ms",
|
||||||
|
transitionTimingFunction: "ease",
|
||||||
|
":hover": { color: t.textPrimary, backgroundColor: rightTab === "overview" ? t.interactiveHover : t.interactiveSubtle },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRightTab("changes")}
|
||||||
|
className={css({
|
||||||
|
appearance: "none",
|
||||||
|
WebkitAppearance: "none",
|
||||||
|
border: "none",
|
||||||
|
marginTop: "6px",
|
||||||
|
marginRight: "0",
|
||||||
|
marginBottom: "6px",
|
||||||
|
marginLeft: "0",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
gap: "6px",
|
gap: "6px",
|
||||||
padding: "4px 12px",
|
padding: "4px 12px",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
|
|
@ -392,7 +441,212 @@ export const RightSidebar = memo(function RightSidebar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollBody>
|
<ScrollBody>
|
||||||
{rightTab === "changes" ? (
|
{rightTab === "overview" ? (
|
||||||
|
<div className={css({ padding: "16px 14px", display: "flex", flexDirection: "column", gap: "16px" })}>
|
||||||
|
<div className={css({ display: "flex", flexDirection: "column", gap: "8px" })}>
|
||||||
|
<LabelXSmall color={t.textTertiary} $style={{ textTransform: "uppercase", letterSpacing: "0.5px", fontWeight: 600 }}>
|
||||||
|
Owner
|
||||||
|
</LabelXSmall>
|
||||||
|
<div ref={ownerDropdownRef} className={css({ position: "relative" })}>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setOwnerDropdownOpen((prev) => !prev)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") setOwnerDropdownOpen((prev) => !prev);
|
||||||
|
}}
|
||||||
|
className={css({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
paddingTop: "4px",
|
||||||
|
paddingRight: "8px",
|
||||||
|
paddingBottom: "4px",
|
||||||
|
paddingLeft: "4px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
":hover": { backgroundColor: t.interactiveHover },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{task.primaryUserLogin ? (
|
||||||
|
<>
|
||||||
|
{task.primaryUserAvatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={task.primaryUserAvatarUrl}
|
||||||
|
alt={task.primaryUserLogin}
|
||||||
|
className={css({
|
||||||
|
width: "28px",
|
||||||
|
height: "28px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
width: "28px",
|
||||||
|
height: "28px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: t.surfaceElevated,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<User size={14} color={t.textTertiary} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<LabelSmall color={t.textPrimary} $style={{ fontWeight: 500, flex: 1 }}>
|
||||||
|
{task.primaryUserLogin}
|
||||||
|
</LabelSmall>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
width: "28px",
|
||||||
|
height: "28px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: t.surfaceElevated,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<User size={14} color={t.textTertiary} />
|
||||||
|
</div>
|
||||||
|
<LabelSmall color={t.textTertiary} $style={{ flex: 1 }}>
|
||||||
|
No owner assigned
|
||||||
|
</LabelSmall>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ChevronDown size={12} color={t.textTertiary} style={{ flexShrink: 0 }} />
|
||||||
|
</div>
|
||||||
|
{ownerDropdownOpen ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={() => setOwnerDropdownOpen(false)}
|
||||||
|
className={css({ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, zIndex: 99 })}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: "absolute",
|
||||||
|
top: "100%",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
marginTop: "4px",
|
||||||
|
backgroundColor: t.surfaceElevated,
|
||||||
|
borderRadius: "8px",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
|
||||||
|
paddingTop: "4px",
|
||||||
|
paddingBottom: "4px",
|
||||||
|
maxHeight: "200px",
|
||||||
|
overflowY: "auto",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{members.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
onChangeOwner(member);
|
||||||
|
setOwnerDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
onChangeOwner(member);
|
||||||
|
setOwnerDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={css({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
paddingTop: "6px",
|
||||||
|
paddingRight: "12px",
|
||||||
|
paddingBottom: "6px",
|
||||||
|
paddingLeft: "12px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: t.textPrimary,
|
||||||
|
":hover": { backgroundColor: t.interactiveHover },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
width: "20px",
|
||||||
|
height: "20px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: t.surfacePrimary,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<User size={10} color={t.textTertiary} />
|
||||||
|
</div>
|
||||||
|
<span>{member.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
paddingTop: "8px",
|
||||||
|
paddingRight: "12px",
|
||||||
|
paddingBottom: "8px",
|
||||||
|
paddingLeft: "12px",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: t.textTertiary,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
No members
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={css({ display: "flex", flexDirection: "column", gap: "8px" })}>
|
||||||
|
<LabelXSmall color={t.textTertiary} $style={{ textTransform: "uppercase", letterSpacing: "0.5px", fontWeight: 600 }}>
|
||||||
|
Branch
|
||||||
|
</LabelXSmall>
|
||||||
|
<div className={css({ display: "flex", alignItems: "center", gap: "8px" })}>
|
||||||
|
<GitBranch size={14} color={t.textTertiary} style={{ flexShrink: 0 }} />
|
||||||
|
<LabelSmall
|
||||||
|
color={t.textSecondary}
|
||||||
|
$style={{ fontFamily: '"IBM Plex Mono", monospace', overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{task.branch ?? "No branch"}
|
||||||
|
</LabelSmall>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={css({ display: "flex", flexDirection: "column", gap: "8px" })}>
|
||||||
|
<LabelXSmall color={t.textTertiary} $style={{ textTransform: "uppercase", letterSpacing: "0.5px", fontWeight: 600 }}>
|
||||||
|
Repository
|
||||||
|
</LabelXSmall>
|
||||||
|
<LabelSmall color={t.textSecondary}>{task.repoName}</LabelSmall>
|
||||||
|
</div>
|
||||||
|
{task.pullRequest ? (
|
||||||
|
<div className={css({ display: "flex", flexDirection: "column", gap: "8px" })}>
|
||||||
|
<LabelXSmall color={t.textTertiary} $style={{ textTransform: "uppercase", letterSpacing: "0.5px", fontWeight: 600 }}>
|
||||||
|
Pull Request
|
||||||
|
</LabelXSmall>
|
||||||
|
<div className={css({ display: "flex", alignItems: "center", gap: "8px" })}>
|
||||||
|
<GitPullRequest size={14} color={t.textTertiary} style={{ flexShrink: 0 }} />
|
||||||
|
<LabelSmall color={t.textSecondary}>
|
||||||
|
#{task.pullRequest.number} {task.pullRequest.title ?? ""}
|
||||||
|
</LabelSmall>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : rightTab === "changes" ? (
|
||||||
<div className={css({ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "2px" })}>
|
<div className={css({ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "2px" })}>
|
||||||
{task.fileChanges.length === 0 ? (
|
{task.fileChanges.length === 0 ? (
|
||||||
<div className={css({ padding: "20px 0", textAlign: "center" })}>
|
<div className={css({ padding: "20px 0", textAlign: "center" })}>
|
||||||
|
|
|
||||||
|
|
@ -745,6 +745,23 @@ export const Sidebar = memo(function Sidebar({
|
||||||
{task.title}
|
{task.title}
|
||||||
</LabelSmall>
|
</LabelSmall>
|
||||||
</div>
|
</div>
|
||||||
|
{task.primaryUserLogin ? (
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
fontSize: "10px",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: t.statusSuccess,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flexShrink: 0,
|
||||||
|
maxWidth: "80px",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
})}
|
||||||
|
title={task.primaryUserLogin}
|
||||||
|
>
|
||||||
|
{task.primaryUserLogin}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{task.pullRequest != null ? (
|
{task.pullRequest != null ? (
|
||||||
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
||||||
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>
|
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,10 @@ export interface WorkspaceTaskSummary {
|
||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
/** Summary of sessions — no transcript content. */
|
/** Summary of sessions — no transcript content. */
|
||||||
sessionsSummary: WorkspaceSessionSummary[];
|
sessionsSummary: WorkspaceSessionSummary[];
|
||||||
|
/** GitHub login of the current primary user (task owner). */
|
||||||
|
primaryUserLogin: string | null;
|
||||||
|
/** Avatar URL of the current primary user. */
|
||||||
|
primaryUserAvatarUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full task detail — only fetched when viewing a specific task. */
|
/** Full task detail — only fetched when viewing a specific task. */
|
||||||
|
|
@ -218,6 +222,10 @@ export interface WorkspaceTask {
|
||||||
fileTree: WorkspaceFileTreeNode[];
|
fileTree: WorkspaceFileTreeNode[];
|
||||||
minutesUsed: number;
|
minutesUsed: number;
|
||||||
activeSandboxId?: string | null;
|
activeSandboxId?: string | null;
|
||||||
|
/** GitHub login of the current primary user (task owner). */
|
||||||
|
primaryUserLogin?: string | null;
|
||||||
|
/** Avatar URL of the current primary user. */
|
||||||
|
primaryUserAvatarUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceRepo {
|
export interface WorkspaceRepo {
|
||||||
|
|
@ -295,6 +303,18 @@ export interface TaskWorkspaceSetSessionUnreadInput extends TaskWorkspaceSession
|
||||||
unread: boolean;
|
unread: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskWorkspaceChangeOwnerInput {
|
||||||
|
repoId: string;
|
||||||
|
taskId: string;
|
||||||
|
/** User ID of the target owner (from FoundryOrganizationMember.id). */
|
||||||
|
targetUserId: string;
|
||||||
|
/** Display name to use as fallback if GitHub login cannot be resolved. */
|
||||||
|
targetUserName: string;
|
||||||
|
/** Email to use as fallback if GitHub email cannot be resolved. */
|
||||||
|
targetUserEmail: string;
|
||||||
|
authSessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskWorkspaceDiffInput {
|
export interface TaskWorkspaceDiffInput {
|
||||||
repoId: string;
|
repoId: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue