mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 06:04:43 +00:00
wip
This commit is contained in:
parent
70d31f819c
commit
4d20f39d4f
47 changed files with 2605 additions and 669 deletions
|
|
@ -23,6 +23,9 @@ GITHUB_APP_PRIVATE_KEY=
|
|||
# Webhook secret for verifying GitHub webhook payloads.
|
||||
# Use smee.io for local development: https://smee.io/new
|
||||
GITHUB_WEBHOOK_SECRET=
|
||||
# Required for local GitHub webhook forwarding in compose.dev.
|
||||
SMEE_URL=
|
||||
SMEE_TARGET=http://backend:7741/v1/webhooks/github
|
||||
|
||||
# Fill these in when enabling live Stripe billing.
|
||||
STRIPE_SECRET_KEY=
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ Recommended GitHub App permissions:
|
|||
|
||||
Set the webhook URL to `https://<your-backend-host>/v1/webhooks/github` and generate a webhook secret. Store the secret as `GITHUB_WEBHOOK_SECRET`.
|
||||
|
||||
This is required, not optional. Foundry depends on GitHub App webhook delivery for installation lifecycle changes, repo access changes, and ongoing repo / pull request sync. If the GitHub App is not installed for the workspace, or webhook delivery is misconfigured, Foundry will remain in an install / reconnect state and core GitHub-backed functionality will not work correctly.
|
||||
|
||||
Recommended webhook subscriptions:
|
||||
|
||||
- `installation`
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ The client subscribes to `app` always, `workspace` when entering a workspace, `t
|
|||
- If a requested UI cannot be implemented cleanly with an existing `BaseUI` component, stop and ask the user whether they are sure they want to diverge from the system.
|
||||
- In that case, recommend the closest existing `BaseUI` components or compositions that could satisfy the need before proposing custom UI work.
|
||||
- Only introduce custom UI primitives when `BaseUI` and existing Foundry patterns are not sufficient, or when the user explicitly confirms they want the divergence.
|
||||
- **Styletron atomic CSS rule:** Never mix CSS shorthand properties with their longhand equivalents in the same style object (including nested pseudo-selectors like `:hover`), or in a base styled component whose consumers override with longhand via `$style`. This includes `padding`/`paddingLeft`, `margin`/`marginTop`, `background`/`backgroundColor`, `border`/`borderLeft`, etc. Styletron generates independent atomic classes for shorthand and longhand, so they conflict unpredictably. Use `backgroundColor: "transparent"` instead of `background: "none"` for button resets. Always use longhand properties when any side may be overridden individually.
|
||||
|
||||
## Runtime Policy
|
||||
|
||||
|
|
@ -201,15 +202,37 @@ For all Rivet/RivetKit implementation:
|
|||
- Do not build blocking flows that wait on external systems to become ready or complete. Prefer push-based progression driven by actor messages, events, webhooks, or queue/workflow state changes.
|
||||
- Use workflows/background commands for any repo sync, sandbox provisioning, agent install, branch restack/rebase, or other multi-step external work. Do not keep user-facing actions/requests open while that work runs.
|
||||
- `send` policy: always `await` the `send(...)` call itself so enqueue failures surface immediately, but default to `wait: false`.
|
||||
- Only use `send(..., { wait: true })` for short, bounded local mutations (e.g. a DB write that returns a result the caller needs). Never use `wait: true` for operations that depend on external readiness, polling actors, provider setup, repo/network I/O, sandbox sessions, GitHub API calls, or long-running queue drains.
|
||||
- Never self-send with `wait: true` from inside a workflow handler — the workflow processes one message at a time, so the handler would deadlock waiting for the new message to be dequeued.
|
||||
- When an action is void-returning and triggers external work, use `wait: false` and let the UI react to state changes pushed by the workflow.
|
||||
- Request/action contract: wait only until the minimum resource needed for the client's next step exists. Example: task creation may wait for task actor creation/identity, but not for sandbox provisioning or session bootstrap.
|
||||
- Read paths must not force refresh/sync work inline. Serve the latest cached projection, mark staleness explicitly, and trigger background refresh separately when needed.
|
||||
- If a workflow needs to resume after some external work completes, model that as workflow state plus follow-up messages/events instead of holding the original request open.
|
||||
- No retries: never add retry loops (`withRetries`, `setTimeout` retry, exponential backoff) anywhere in the codebase. If an operation fails, surface the error immediately. If a dependency is not ready yet, model that explicitly with workflow state and resume from a push/event instead of polling or retry loops.
|
||||
- Never throw errors that expect the caller to retry (e.g. `throw new Error("... retry shortly")`). If a dependency is not ready, write the current state to the DB with an appropriate pending status, enqueue the async work, and return successfully. Let the client observe the pending → ready transition via push events.
|
||||
- Action return contract: every action that creates a resource must write the resource record to the DB before returning, so the client can immediately query/render it. The record may have a pending status, but it must exist. Never return an ID that doesn't yet have a corresponding DB row.
|
||||
|
||||
### Action handler responsiveness
|
||||
|
||||
Action handlers must return fast. The pattern:
|
||||
|
||||
1. **Creating an entity** — `wait: true` is fine. Do the DB write, return the ID/record. The caller needs the ID to proceed. The record may have a pending status; that's expected.
|
||||
2. **Enqueuing work** (sending a message, triggering a sandbox operation, starting a sync) — `wait: false`. Write any precondition state to the DB synchronously, enqueue the work, and return. The client observes progress via push events on the relevant topic (session status, task status, etc.).
|
||||
3. **Validating preconditions** — check state synchronously in the action handler *before* enqueuing. If a precondition isn't met (e.g. session not ready, task not initialized), throw an error immediately. Do not implicitly provision missing dependencies or poll for readiness inside the action handler. It is the client's responsibility to ensure preconditions are met before calling the action.
|
||||
|
||||
Examples:
|
||||
- `createTask` → `wait: true` (returns `{ taskId }`), then enqueue provisioning with `wait: false`. Client sees task appear immediately with pending status, observes `ready` via workspace events.
|
||||
- `sendWorkbenchMessage` → validate session is `ready` (throw if not), enqueue with `wait: false`. Client observes session transition to `running` → `idle` via session events.
|
||||
- `createWorkbenchSession` → `wait: true` (returns `{ tabId }`), enqueue sandbox provisioning with `wait: false`. Client observes `pending_provision` → `ready` via task events.
|
||||
|
||||
Never use `wait: true` for operations that depend on external readiness, sandbox I/O, agent responses, git network operations, polling loops, or long-running queue drains. Never hold an action open while waiting for an external system to become ready — that is a polling/retry loop in disguise.
|
||||
|
||||
### Task creation: resolve metadata before creating the actor
|
||||
|
||||
When creating a task, all deterministic metadata (title, branch name) must be resolved synchronously in the parent actor (project) *before* the task actor is created. The task actor must never be created with null `branchName` or `title`.
|
||||
|
||||
- Title is derived from the task description via `deriveFallbackTitle()` — pure string manipulation, no external I/O.
|
||||
- Branch name is derived from the title via `sanitizeBranchName()` + conflict checking against remote branches and the project's task index.
|
||||
- The project actor already has the repo clone and task index. Do the git fetch + name resolution there.
|
||||
- Do not defer naming to a background provision workflow. Do not poll for names to become available.
|
||||
- The `onBranch` path (attaching to an existing branch) and the new-task path should both produce a fully-named task record on return.
|
||||
- Actor handle policy:
|
||||
- Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`.
|
||||
- Use `get`/`getForId` when the actor is expected to already exist; if missing, surface an explicit `Actor not found` error with recovery context.
|
||||
|
|
@ -235,6 +258,8 @@ For all Rivet/RivetKit implementation:
|
|||
- For Foundry live verification, use `rivet-dev/sandbox-agent-testing` as the default testing repo unless the task explicitly says otherwise.
|
||||
- Secrets (e.g. `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`) must be provided via environment variables, never hardcoded in the repo.
|
||||
- `~/misc/env.txt` and `~/misc/the-foundry.env` contain the expected local OpenAI + GitHub OAuth/App config for dev.
|
||||
- For local GitHub webhook development, use the configured Smee proxy (`SMEE_URL`) to forward deliveries into `POST /v1/webhooks/github`. Check `.env` / `foundry/.env` if you need the current channel URL.
|
||||
- If GitHub repos, PRs, or install state are not showing up, verify that the GitHub App is installed for the workspace and that webhook delivery is enabled and healthy. Foundry depends on webhook events for GitHub-backed state; missing webhooks means the product will appear broken.
|
||||
- Do not assume `gh auth token` is sufficient for Foundry task provisioning against private repos. Sandbox/bootstrap git clone, push, and PR flows require a repo-capable `GITHUB_TOKEN`/`GH_TOKEN` in the backend container.
|
||||
- Preferred product behavior for org workspaces is to mint a GitHub App installation token from the workspace installation and inject it into backend/sandbox git operations. Do not rely on an operator's ambient CLI auth as the long-term solution.
|
||||
- Treat client E2E tests in `packages/client/test` as the primary end-to-end source of truth for product behavior.
|
||||
|
|
|
|||
|
|
@ -93,6 +93,27 @@ services:
|
|||
- "foundry_shared_node_modules:/app/foundry/packages/shared/node_modules"
|
||||
- "foundry_pnpm_store:/tmp/.local/share/pnpm/store"
|
||||
|
||||
smee:
|
||||
image: node:20-alpine
|
||||
depends_on:
|
||||
- backend
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
SMEE_URL: "${SMEE_URL:-}"
|
||||
SMEE_TARGET: "${SMEE_TARGET:-http://backend:7741/v1/webhooks/github}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- -lc
|
||||
- |
|
||||
if [ -z "$SMEE_URL" ]; then
|
||||
echo "SMEE_URL is required for local GitHub webhook forwarding" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec npx --yes smee-client --url "$SMEE_URL" --target "$SMEE_TARGET"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
foundry_backend_root_node_modules: {}
|
||||
foundry_backend_backend_node_modules: {}
|
||||
|
|
|
|||
5
foundry/packages/backend/src/actors/github-data/db/db.ts
Normal file
5
foundry/packages/backend/src/actors/github-data/db/db.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { db } from "rivetkit/db/drizzle";
|
||||
import * as schema from "./schema.js";
|
||||
import migrations from "./migrations.js";
|
||||
|
||||
export const githubDataDb = db({ schema, migrations });
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
const journal = {
|
||||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
when: 1773446400000,
|
||||
tag: "0000_github_data",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export default {
|
||||
journal,
|
||||
migrations: {
|
||||
m0000: `CREATE TABLE \`github_meta\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`connected_account\` text NOT NULL,
|
||||
\`installation_status\` text NOT NULL,
|
||||
\`sync_status\` text NOT NULL,
|
||||
\`installation_id\` integer,
|
||||
\`last_sync_label\` text NOT NULL,
|
||||
\`last_sync_at\` integer,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`github_repositories\` (
|
||||
\`repo_id\` text PRIMARY KEY NOT NULL,
|
||||
\`full_name\` text NOT NULL,
|
||||
\`clone_url\` text NOT NULL,
|
||||
\`private\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`github_members\` (
|
||||
\`member_id\` text PRIMARY KEY NOT NULL,
|
||||
\`login\` text NOT NULL,
|
||||
\`display_name\` text NOT NULL,
|
||||
\`email\` text,
|
||||
\`role\` text,
|
||||
\`state\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`github_pull_requests\` (
|
||||
\`pr_id\` text PRIMARY KEY NOT NULL,
|
||||
\`repo_id\` text NOT NULL,
|
||||
\`repo_full_name\` text NOT NULL,
|
||||
\`number\` integer NOT NULL,
|
||||
\`title\` text NOT NULL,
|
||||
\`body\` text,
|
||||
\`state\` text NOT NULL,
|
||||
\`url\` text NOT NULL,
|
||||
\`head_ref_name\` text NOT NULL,
|
||||
\`base_ref_name\` text NOT NULL,
|
||||
\`author_login\` text,
|
||||
\`is_draft\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
46
foundry/packages/backend/src/actors/github-data/db/schema.ts
Normal file
46
foundry/packages/backend/src/actors/github-data/db/schema.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
|
||||
export const githubMeta = sqliteTable("github_meta", {
|
||||
id: integer("id").primaryKey(),
|
||||
connectedAccount: text("connected_account").notNull(),
|
||||
installationStatus: text("installation_status").notNull(),
|
||||
syncStatus: text("sync_status").notNull(),
|
||||
installationId: integer("installation_id"),
|
||||
lastSyncLabel: text("last_sync_label").notNull(),
|
||||
lastSyncAt: integer("last_sync_at"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const githubRepositories = sqliteTable("github_repositories", {
|
||||
repoId: text("repo_id").notNull().primaryKey(),
|
||||
fullName: text("full_name").notNull(),
|
||||
cloneUrl: text("clone_url").notNull(),
|
||||
private: integer("private").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const githubMembers = sqliteTable("github_members", {
|
||||
memberId: text("member_id").notNull().primaryKey(),
|
||||
login: text("login").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
email: text("email"),
|
||||
role: text("role"),
|
||||
state: text("state").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const githubPullRequests = sqliteTable("github_pull_requests", {
|
||||
prId: text("pr_id").notNull().primaryKey(),
|
||||
repoId: text("repo_id").notNull(),
|
||||
repoFullName: text("repo_full_name").notNull(),
|
||||
number: integer("number").notNull(),
|
||||
title: text("title").notNull(),
|
||||
body: text("body"),
|
||||
state: text("state").notNull(),
|
||||
url: text("url").notNull(),
|
||||
headRefName: text("head_ref_name").notNull(),
|
||||
baseRefName: text("base_ref_name").notNull(),
|
||||
authorLogin: text("author_login"),
|
||||
isDraft: integer("is_draft").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
775
foundry/packages/backend/src/actors/github-data/index.ts
Normal file
775
foundry/packages/backend/src/actors/github-data/index.ts
Normal file
|
|
@ -0,0 +1,775 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { actor } from "rivetkit";
|
||||
import type { FoundryOrganization } from "@sandbox-agent/foundry-shared";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getOrCreateWorkspace, getTask } from "../handles.js";
|
||||
import { repoIdFromRemote } from "../../services/repo.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { githubDataDb } from "./db/db.js";
|
||||
import { githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js";
|
||||
|
||||
const META_ROW_ID = 1;
|
||||
|
||||
interface GithubDataInput {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface GithubMemberRecord {
|
||||
id: string;
|
||||
login: string;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
role?: string | null;
|
||||
state?: string | null;
|
||||
}
|
||||
|
||||
interface GithubRepositoryRecord {
|
||||
fullName: string;
|
||||
cloneUrl: string;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
interface GithubPullRequestRecord {
|
||||
repoId: string;
|
||||
repoFullName: string;
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
authorLogin: string | null;
|
||||
isDraft: boolean;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface FullSyncInput {
|
||||
connectedAccount?: string | null;
|
||||
installationStatus?: FoundryOrganization["github"]["installationStatus"];
|
||||
installationId?: number | null;
|
||||
githubLogin?: string | null;
|
||||
kind?: FoundryOrganization["kind"] | null;
|
||||
accessToken?: string | null;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
interface ClearStateInput {
|
||||
connectedAccount: string;
|
||||
installationStatus: FoundryOrganization["github"]["installationStatus"];
|
||||
installationId: number | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PullRequestWebhookInput {
|
||||
connectedAccount: string;
|
||||
installationStatus: FoundryOrganization["github"]["installationStatus"];
|
||||
installationId: number | null;
|
||||
repository: {
|
||||
fullName: string;
|
||||
cloneUrl: string;
|
||||
private: boolean;
|
||||
};
|
||||
pullRequest: {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
authorLogin: string | null;
|
||||
isDraft: boolean;
|
||||
merged?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePrStatus(input: { state: string; isDraft?: boolean; merged?: boolean }): "OPEN" | "DRAFT" | "CLOSED" | "MERGED" {
|
||||
const state = input.state.trim().toUpperCase();
|
||||
if (input.merged || state === "MERGED") return "MERGED";
|
||||
if (state === "CLOSED") return "CLOSED";
|
||||
return input.isDraft ? "DRAFT" : "OPEN";
|
||||
}
|
||||
|
||||
function pullRequestSummaryFromRow(row: any) {
|
||||
return {
|
||||
prId: row.prId,
|
||||
repoId: row.repoId,
|
||||
repoFullName: row.repoFullName,
|
||||
number: row.number,
|
||||
title: row.title,
|
||||
state: row.state,
|
||||
url: row.url,
|
||||
headRefName: row.headRefName,
|
||||
baseRefName: row.baseRefName,
|
||||
authorLogin: row.authorLogin ?? null,
|
||||
isDraft: Boolean(row.isDraft),
|
||||
updatedAtMs: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function readMeta(c: any) {
|
||||
const row = await c.db.select().from(githubMeta).where(eq(githubMeta.id, META_ROW_ID)).get();
|
||||
return {
|
||||
connectedAccount: row?.connectedAccount ?? "",
|
||||
installationStatus: (row?.installationStatus ?? "install_required") as FoundryOrganization["github"]["installationStatus"],
|
||||
syncStatus: (row?.syncStatus ?? "pending") as FoundryOrganization["github"]["syncStatus"],
|
||||
installationId: row?.installationId ?? null,
|
||||
lastSyncLabel: row?.lastSyncLabel ?? "Waiting for first import",
|
||||
lastSyncAt: row?.lastSyncAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeMeta(c: any, patch: Partial<Awaited<ReturnType<typeof readMeta>>>) {
|
||||
const current = await readMeta(c);
|
||||
const next = {
|
||||
...current,
|
||||
...patch,
|
||||
};
|
||||
await c.db
|
||||
.insert(githubMeta)
|
||||
.values({
|
||||
id: META_ROW_ID,
|
||||
connectedAccount: next.connectedAccount,
|
||||
installationStatus: next.installationStatus,
|
||||
syncStatus: next.syncStatus,
|
||||
installationId: next.installationId,
|
||||
lastSyncLabel: next.lastSyncLabel,
|
||||
lastSyncAt: next.lastSyncAt,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: githubMeta.id,
|
||||
set: {
|
||||
connectedAccount: next.connectedAccount,
|
||||
installationStatus: next.installationStatus,
|
||||
syncStatus: next.syncStatus,
|
||||
installationId: next.installationId,
|
||||
lastSyncLabel: next.lastSyncLabel,
|
||||
lastSyncAt: next.lastSyncAt,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
.run();
|
||||
return next;
|
||||
}
|
||||
|
||||
async function getOrganizationContext(c: any, overrides?: FullSyncInput) {
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
const organization = await workspace.getOrganizationShellStateIfInitialized({});
|
||||
if (!organization) {
|
||||
throw new Error(`Workspace ${c.state.workspaceId} is not initialized`);
|
||||
}
|
||||
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
||||
return {
|
||||
kind: overrides?.kind ?? organization.snapshot.kind,
|
||||
githubLogin: overrides?.githubLogin ?? organization.githubLogin,
|
||||
connectedAccount: overrides?.connectedAccount ?? organization.snapshot.github.connectedAccount ?? organization.githubLogin,
|
||||
installationId: overrides?.installationId ?? organization.githubInstallationId ?? null,
|
||||
installationStatus:
|
||||
overrides?.installationStatus ??
|
||||
organization.snapshot.github.installationStatus ??
|
||||
(organization.snapshot.kind === "personal" ? "connected" : "reconnect_required"),
|
||||
accessToken: overrides?.accessToken ?? auth?.githubToken ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function replaceRepositories(c: any, repositories: GithubRepositoryRecord[], updatedAt: number) {
|
||||
await c.db.delete(githubRepositories).run();
|
||||
for (const repository of repositories) {
|
||||
await c.db
|
||||
.insert(githubRepositories)
|
||||
.values({
|
||||
repoId: repoIdFromRemote(repository.cloneUrl),
|
||||
fullName: repository.fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
private: repository.private ? 1 : 0,
|
||||
updatedAt,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceMembers(c: any, members: GithubMemberRecord[], updatedAt: number) {
|
||||
await c.db.delete(githubMembers).run();
|
||||
for (const member of members) {
|
||||
await c.db
|
||||
.insert(githubMembers)
|
||||
.values({
|
||||
memberId: member.id,
|
||||
login: member.login,
|
||||
displayName: member.name || member.login,
|
||||
email: member.email ?? null,
|
||||
role: member.role ?? null,
|
||||
state: member.state ?? "active",
|
||||
updatedAt,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
async function replacePullRequests(c: any, pullRequests: GithubPullRequestRecord[]) {
|
||||
await c.db.delete(githubPullRequests).run();
|
||||
for (const pullRequest of pullRequests) {
|
||||
await c.db
|
||||
.insert(githubPullRequests)
|
||||
.values({
|
||||
prId: `${pullRequest.repoId}#${pullRequest.number}`,
|
||||
repoId: pullRequest.repoId,
|
||||
repoFullName: pullRequest.repoFullName,
|
||||
number: pullRequest.number,
|
||||
title: pullRequest.title,
|
||||
body: pullRequest.body ?? null,
|
||||
state: pullRequest.state,
|
||||
url: pullRequest.url,
|
||||
headRefName: pullRequest.headRefName,
|
||||
baseRefName: pullRequest.baseRefName,
|
||||
authorLogin: pullRequest.authorLogin ?? null,
|
||||
isDraft: pullRequest.isDraft ? 1 : 0,
|
||||
updatedAt: pullRequest.updatedAt,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTaskSummaryForBranch(c: any, repoId: string, branchName: string) {
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.refreshTaskSummaryForGithubBranch({ repoId, branchName });
|
||||
}
|
||||
|
||||
async function emitPullRequestChangeEvents(c: any, beforeRows: any[], afterRows: any[]) {
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
const beforeById = new Map(beforeRows.map((row) => [row.prId, row]));
|
||||
const afterById = new Map(afterRows.map((row) => [row.prId, row]));
|
||||
|
||||
for (const [prId, row] of afterById) {
|
||||
const previous = beforeById.get(prId);
|
||||
const changed =
|
||||
!previous ||
|
||||
previous.title !== row.title ||
|
||||
previous.state !== row.state ||
|
||||
previous.url !== row.url ||
|
||||
previous.headRefName !== row.headRefName ||
|
||||
previous.baseRefName !== row.baseRefName ||
|
||||
previous.authorLogin !== row.authorLogin ||
|
||||
previous.isDraft !== row.isDraft ||
|
||||
previous.updatedAt !== row.updatedAt;
|
||||
if (!changed) {
|
||||
continue;
|
||||
}
|
||||
await workspace.applyOpenPullRequestUpdate({
|
||||
pullRequest: pullRequestSummaryFromRow(row),
|
||||
});
|
||||
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName);
|
||||
}
|
||||
|
||||
for (const [prId, row] of beforeById) {
|
||||
if (afterById.has(prId)) {
|
||||
continue;
|
||||
}
|
||||
await workspace.removeOpenPullRequest({ prId });
|
||||
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName);
|
||||
}
|
||||
}
|
||||
|
||||
async function autoArchiveTaskForClosedPullRequest(c: any, row: any) {
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
const match = await workspace.findTaskForGithubBranch({
|
||||
repoId: row.repoId,
|
||||
branchName: row.headRefName,
|
||||
});
|
||||
if (!match?.taskId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const task = getTask(c, c.state.workspaceId, row.repoId, match.taskId);
|
||||
await task.archive({ reason: `PR ${String(row.state).toLowerCase()}` });
|
||||
} catch {
|
||||
// Best-effort only. Task summary refresh will still clear the PR state.
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRepositories(c: any, context: Awaited<ReturnType<typeof getOrganizationContext>>): Promise<GithubRepositoryRecord[]> {
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
if (context.kind === "personal") {
|
||||
if (!context.accessToken) {
|
||||
return [];
|
||||
}
|
||||
return await appShell.github.listUserRepositories(context.accessToken);
|
||||
}
|
||||
|
||||
if (context.installationId != null) {
|
||||
try {
|
||||
return await appShell.github.listInstallationRepositories(context.installationId);
|
||||
} catch (error) {
|
||||
if (!context.accessToken) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.accessToken) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (await appShell.github.listUserRepositories(context.accessToken)).filter((repository) => repository.fullName.startsWith(`${context.githubLogin}/`));
|
||||
}
|
||||
|
||||
async function resolveMembers(c: any, context: Awaited<ReturnType<typeof getOrganizationContext>>): Promise<GithubMemberRecord[]> {
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
if (context.kind === "personal") {
|
||||
return [];
|
||||
}
|
||||
if (context.installationId != null) {
|
||||
try {
|
||||
return await appShell.github.listInstallationMembers(context.installationId, context.githubLogin);
|
||||
} catch (error) {
|
||||
if (!context.accessToken) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!context.accessToken) {
|
||||
return [];
|
||||
}
|
||||
return await appShell.github.listOrganizationMembers(context.accessToken, context.githubLogin);
|
||||
}
|
||||
|
||||
async function resolvePullRequests(
|
||||
c: any,
|
||||
context: Awaited<ReturnType<typeof getOrganizationContext>>,
|
||||
repositories: GithubRepositoryRecord[],
|
||||
): Promise<GithubPullRequestRecord[]> {
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
if (repositories.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let pullRequests: Array<{
|
||||
repoFullName: string;
|
||||
cloneUrl: string;
|
||||
number: number;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
state: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
authorLogin?: string | null;
|
||||
isDraft?: boolean;
|
||||
merged?: boolean;
|
||||
}> = [];
|
||||
|
||||
if (context.installationId != null) {
|
||||
try {
|
||||
pullRequests = await appShell.github.listInstallationPullRequestsForRepositories(context.installationId, repositories);
|
||||
} catch (error) {
|
||||
if (!context.accessToken) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pullRequests.length === 0 && context.accessToken) {
|
||||
pullRequests = await appShell.github.listPullRequestsForUserRepositories(context.accessToken, repositories);
|
||||
}
|
||||
|
||||
return pullRequests.map((pullRequest) => ({
|
||||
repoId: repoIdFromRemote(pullRequest.cloneUrl),
|
||||
repoFullName: pullRequest.repoFullName,
|
||||
number: pullRequest.number,
|
||||
title: pullRequest.title,
|
||||
body: pullRequest.body ?? null,
|
||||
state: normalizePrStatus(pullRequest),
|
||||
url: pullRequest.url,
|
||||
headRefName: pullRequest.headRefName,
|
||||
baseRefName: pullRequest.baseRefName,
|
||||
authorLogin: pullRequest.authorLogin ?? null,
|
||||
isDraft: Boolean(pullRequest.isDraft),
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function readAllPullRequestRows(c: any) {
|
||||
return await c.db.select().from(githubPullRequests).all();
|
||||
}
|
||||
|
||||
async function runFullSync(c: any, input: FullSyncInput = {}) {
|
||||
const startedAt = Date.now();
|
||||
const beforeRows = await readAllPullRequestRows(c);
|
||||
const context = await getOrganizationContext(c, input);
|
||||
|
||||
await writeMeta(c, {
|
||||
connectedAccount: context.connectedAccount,
|
||||
installationStatus: context.installationStatus,
|
||||
installationId: context.installationId,
|
||||
syncStatus: "syncing",
|
||||
lastSyncLabel: input.label?.trim() || "Syncing GitHub data...",
|
||||
});
|
||||
|
||||
const repositories = await resolveRepositories(c, context);
|
||||
const members = await resolveMembers(c, context);
|
||||
const pullRequests = await resolvePullRequests(c, context, repositories);
|
||||
|
||||
await replaceRepositories(c, repositories, startedAt);
|
||||
await replaceMembers(c, members, startedAt);
|
||||
await replacePullRequests(c, pullRequests);
|
||||
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.applyGithubDataProjection({
|
||||
connectedAccount: context.connectedAccount,
|
||||
installationStatus: context.installationStatus,
|
||||
installationId: context.installationId,
|
||||
syncStatus: "synced",
|
||||
lastSyncLabel: repositories.length > 0 ? `Synced ${repositories.length} repositories` : "No repositories available",
|
||||
lastSyncAt: startedAt,
|
||||
repositories,
|
||||
});
|
||||
|
||||
const meta = await writeMeta(c, {
|
||||
connectedAccount: context.connectedAccount,
|
||||
installationStatus: context.installationStatus,
|
||||
installationId: context.installationId,
|
||||
syncStatus: "synced",
|
||||
lastSyncLabel: repositories.length > 0 ? `Synced ${repositories.length} repositories` : "No repositories available",
|
||||
lastSyncAt: startedAt,
|
||||
});
|
||||
|
||||
const afterRows = await readAllPullRequestRows(c);
|
||||
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
|
||||
|
||||
return {
|
||||
...meta,
|
||||
repositoryCount: repositories.length,
|
||||
memberCount: members.length,
|
||||
pullRequestCount: afterRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
export const githubData = actor({
|
||||
db: githubDataDb,
|
||||
options: {
|
||||
name: "GitHub Data",
|
||||
icon: "github",
|
||||
actionTimeout: 5 * 60_000,
|
||||
},
|
||||
createState: (_c, input: GithubDataInput) => ({
|
||||
workspaceId: input.workspaceId,
|
||||
}),
|
||||
actions: {
|
||||
async getSummary(c) {
|
||||
const repositories = await c.db.select().from(githubRepositories).all();
|
||||
const members = await c.db.select().from(githubMembers).all();
|
||||
const pullRequests = await c.db.select().from(githubPullRequests).all();
|
||||
return {
|
||||
...(await readMeta(c)),
|
||||
repositoryCount: repositories.length,
|
||||
memberCount: members.length,
|
||||
pullRequestCount: pullRequests.length,
|
||||
};
|
||||
},
|
||||
|
||||
async listRepositories(c) {
|
||||
const rows = await c.db.select().from(githubRepositories).all();
|
||||
return rows.map((row) => ({
|
||||
repoId: row.repoId,
|
||||
fullName: row.fullName,
|
||||
cloneUrl: row.cloneUrl,
|
||||
private: Boolean(row.private),
|
||||
}));
|
||||
},
|
||||
|
||||
async listPullRequestsForRepository(c, input: { repoId: string }) {
|
||||
const rows = await c.db.select().from(githubPullRequests).where(eq(githubPullRequests.repoId, input.repoId)).all();
|
||||
return rows.map(pullRequestSummaryFromRow);
|
||||
},
|
||||
|
||||
async listOpenPullRequests(c) {
|
||||
const rows = await c.db.select().from(githubPullRequests).all();
|
||||
return rows.map(pullRequestSummaryFromRow).sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||
},
|
||||
|
||||
async getPullRequestForBranch(c, input: { repoId: string; branchName: string }) {
|
||||
const rows = await c.db.select().from(githubPullRequests).where(eq(githubPullRequests.repoId, input.repoId)).all();
|
||||
const match = rows.find((candidate) => candidate.headRefName === input.branchName) ?? null;
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
number: match.number,
|
||||
status: match.isDraft ? ("draft" as const) : ("ready" as const),
|
||||
};
|
||||
},
|
||||
|
||||
async fullSync(c, input: FullSyncInput = {}) {
|
||||
return await runFullSync(c, input);
|
||||
},
|
||||
|
||||
async reloadOrganization(c) {
|
||||
return await runFullSync(c, { label: "Reloading GitHub organization..." });
|
||||
},
|
||||
|
||||
async reloadAllPullRequests(c) {
|
||||
return await runFullSync(c, { label: "Reloading GitHub pull requests..." });
|
||||
},
|
||||
|
||||
async reloadRepository(c, input: { repoId: string }) {
|
||||
const context = await getOrganizationContext(c);
|
||||
const current = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, input.repoId)).get();
|
||||
if (!current) {
|
||||
throw new Error(`Unknown GitHub repository: ${input.repoId}`);
|
||||
}
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const repository =
|
||||
context.installationId != null
|
||||
? await appShell.github.getInstallationRepository(context.installationId, current.fullName)
|
||||
: context.accessToken
|
||||
? await appShell.github.getUserRepository(context.accessToken, current.fullName)
|
||||
: null;
|
||||
if (!repository) {
|
||||
throw new Error(`Unable to reload repository: ${current.fullName}`);
|
||||
}
|
||||
|
||||
const updatedAt = Date.now();
|
||||
await c.db
|
||||
.insert(githubRepositories)
|
||||
.values({
|
||||
repoId: input.repoId,
|
||||
fullName: repository.fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
private: repository.private ? 1 : 0,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: githubRepositories.repoId,
|
||||
set: {
|
||||
fullName: repository.fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
private: repository.private ? 1 : 0,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.applyGithubRepositoryProjection({
|
||||
repoId: input.repoId,
|
||||
remoteUrl: repository.cloneUrl,
|
||||
});
|
||||
return {
|
||||
repoId: input.repoId,
|
||||
fullName: repository.fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
private: repository.private,
|
||||
};
|
||||
},
|
||||
|
||||
async reloadPullRequest(c, input: { repoId: string; prNumber: number }) {
|
||||
const repository = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, input.repoId)).get();
|
||||
if (!repository) {
|
||||
throw new Error(`Unknown GitHub repository: ${input.repoId}`);
|
||||
}
|
||||
const context = await getOrganizationContext(c);
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const pullRequest =
|
||||
context.installationId != null
|
||||
? await appShell.github.getInstallationPullRequest(context.installationId, repository.fullName, input.prNumber)
|
||||
: context.accessToken
|
||||
? await appShell.github.getUserPullRequest(context.accessToken, repository.fullName, input.prNumber)
|
||||
: null;
|
||||
if (!pullRequest) {
|
||||
throw new Error(`Unable to reload pull request #${input.prNumber} for ${repository.fullName}`);
|
||||
}
|
||||
|
||||
const beforeRows = await readAllPullRequestRows(c);
|
||||
const updatedAt = Date.now();
|
||||
const nextState = normalizePrStatus(pullRequest);
|
||||
const prId = `${input.repoId}#${input.prNumber}`;
|
||||
if (nextState === "CLOSED" || nextState === "MERGED") {
|
||||
await c.db.delete(githubPullRequests).where(eq(githubPullRequests.prId, prId)).run();
|
||||
} else {
|
||||
await c.db
|
||||
.insert(githubPullRequests)
|
||||
.values({
|
||||
prId,
|
||||
repoId: input.repoId,
|
||||
repoFullName: repository.fullName,
|
||||
number: pullRequest.number,
|
||||
title: pullRequest.title,
|
||||
body: pullRequest.body ?? null,
|
||||
state: nextState,
|
||||
url: pullRequest.url,
|
||||
headRefName: pullRequest.headRefName,
|
||||
baseRefName: pullRequest.baseRefName,
|
||||
authorLogin: pullRequest.authorLogin ?? null,
|
||||
isDraft: pullRequest.isDraft ? 1 : 0,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: githubPullRequests.prId,
|
||||
set: {
|
||||
title: pullRequest.title,
|
||||
body: pullRequest.body ?? null,
|
||||
state: nextState,
|
||||
url: pullRequest.url,
|
||||
headRefName: pullRequest.headRefName,
|
||||
baseRefName: pullRequest.baseRefName,
|
||||
authorLogin: pullRequest.authorLogin ?? null,
|
||||
isDraft: pullRequest.isDraft ? 1 : 0,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
const afterRows = await readAllPullRequestRows(c);
|
||||
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
|
||||
const closed = afterRows.find((row) => row.prId === prId);
|
||||
if (!closed && (nextState === "CLOSED" || nextState === "MERGED")) {
|
||||
const previous = beforeRows.find((row) => row.prId === prId);
|
||||
if (previous) {
|
||||
await autoArchiveTaskForClosedPullRequest(c, {
|
||||
...previous,
|
||||
state: nextState,
|
||||
});
|
||||
}
|
||||
}
|
||||
return pullRequestSummaryFromRow(
|
||||
afterRows.find((row) => row.prId === prId) ?? {
|
||||
prId,
|
||||
repoId: input.repoId,
|
||||
repoFullName: repository.fullName,
|
||||
number: input.prNumber,
|
||||
title: pullRequest.title,
|
||||
state: nextState,
|
||||
url: pullRequest.url,
|
||||
headRefName: pullRequest.headRefName,
|
||||
baseRefName: pullRequest.baseRefName,
|
||||
authorLogin: pullRequest.authorLogin ?? null,
|
||||
isDraft: pullRequest.isDraft ? 1 : 0,
|
||||
updatedAt,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async clearState(c, input: ClearStateInput) {
|
||||
const beforeRows = await readAllPullRequestRows(c);
|
||||
await c.db.delete(githubPullRequests).run();
|
||||
await c.db.delete(githubRepositories).run();
|
||||
await c.db.delete(githubMembers).run();
|
||||
await writeMeta(c, {
|
||||
connectedAccount: input.connectedAccount,
|
||||
installationStatus: input.installationStatus,
|
||||
installationId: input.installationId,
|
||||
syncStatus: "pending",
|
||||
lastSyncLabel: input.label,
|
||||
lastSyncAt: null,
|
||||
});
|
||||
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.applyGithubDataProjection({
|
||||
connectedAccount: input.connectedAccount,
|
||||
installationStatus: input.installationStatus,
|
||||
installationId: input.installationId,
|
||||
syncStatus: "pending",
|
||||
lastSyncLabel: input.label,
|
||||
lastSyncAt: null,
|
||||
repositories: [],
|
||||
});
|
||||
await emitPullRequestChangeEvents(c, beforeRows, []);
|
||||
},
|
||||
|
||||
async handlePullRequestWebhook(c, input: PullRequestWebhookInput) {
|
||||
const beforeRows = await readAllPullRequestRows(c);
|
||||
const repoId = repoIdFromRemote(input.repository.cloneUrl);
|
||||
const updatedAt = Date.now();
|
||||
const state = normalizePrStatus(input.pullRequest);
|
||||
const prId = `${repoId}#${input.pullRequest.number}`;
|
||||
|
||||
await c.db
|
||||
.insert(githubRepositories)
|
||||
.values({
|
||||
repoId,
|
||||
fullName: input.repository.fullName,
|
||||
cloneUrl: input.repository.cloneUrl,
|
||||
private: input.repository.private ? 1 : 0,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: githubRepositories.repoId,
|
||||
set: {
|
||||
fullName: input.repository.fullName,
|
||||
cloneUrl: input.repository.cloneUrl,
|
||||
private: input.repository.private ? 1 : 0,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
if (state === "CLOSED" || state === "MERGED") {
|
||||
await c.db.delete(githubPullRequests).where(eq(githubPullRequests.prId, prId)).run();
|
||||
} else {
|
||||
await c.db
|
||||
.insert(githubPullRequests)
|
||||
.values({
|
||||
prId,
|
||||
repoId,
|
||||
repoFullName: input.repository.fullName,
|
||||
number: input.pullRequest.number,
|
||||
title: input.pullRequest.title,
|
||||
body: input.pullRequest.body ?? null,
|
||||
state,
|
||||
url: input.pullRequest.url,
|
||||
headRefName: input.pullRequest.headRefName,
|
||||
baseRefName: input.pullRequest.baseRefName,
|
||||
authorLogin: input.pullRequest.authorLogin ?? null,
|
||||
isDraft: input.pullRequest.isDraft ? 1 : 0,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: githubPullRequests.prId,
|
||||
set: {
|
||||
title: input.pullRequest.title,
|
||||
body: input.pullRequest.body ?? null,
|
||||
state,
|
||||
url: input.pullRequest.url,
|
||||
headRefName: input.pullRequest.headRefName,
|
||||
baseRefName: input.pullRequest.baseRefName,
|
||||
authorLogin: input.pullRequest.authorLogin ?? null,
|
||||
isDraft: input.pullRequest.isDraft ? 1 : 0,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
await writeMeta(c, {
|
||||
connectedAccount: input.connectedAccount,
|
||||
installationStatus: input.installationStatus,
|
||||
installationId: input.installationId,
|
||||
syncStatus: "synced",
|
||||
lastSyncLabel: "GitHub webhook received",
|
||||
lastSyncAt: updatedAt,
|
||||
});
|
||||
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.applyGithubRepositoryProjection({
|
||||
repoId,
|
||||
remoteUrl: input.repository.cloneUrl,
|
||||
});
|
||||
|
||||
const afterRows = await readAllPullRequestRows(c);
|
||||
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
|
||||
if (state === "CLOSED" || state === "MERGED") {
|
||||
const previous = beforeRows.find((row) => row.prId === prId);
|
||||
if (previous) {
|
||||
await autoArchiveTaskForClosedPullRequest(c, {
|
||||
...previous,
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { authUserKey, taskKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, taskSandboxKey, workspaceKey } from "./keys.js";
|
||||
import { authUserKey, githubDataKey, taskKey, historyKey, projectBranchSyncKey, projectKey, taskSandboxKey, workspaceKey } from "./keys.js";
|
||||
|
||||
export function actorClient(c: any) {
|
||||
return c.client();
|
||||
|
|
@ -53,17 +53,18 @@ export async function getOrCreateHistory(c: any, workspaceId: string, repoId: st
|
|||
});
|
||||
}
|
||||
|
||||
export async function getOrCreateProjectPrSync(c: any, workspaceId: string, repoId: string, repoPath: string, intervalMs: number) {
|
||||
return await actorClient(c).projectPrSync.getOrCreate(projectPrSyncKey(workspaceId, repoId), {
|
||||
export async function getOrCreateGithubData(c: any, workspaceId: string) {
|
||||
return await actorClient(c).githubData.getOrCreate(githubDataKey(workspaceId), {
|
||||
createWithInput: {
|
||||
workspaceId,
|
||||
repoId,
|
||||
repoPath,
|
||||
intervalMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getGithubData(c: any, workspaceId: string) {
|
||||
return actorClient(c).githubData.get(githubDataKey(workspaceId));
|
||||
}
|
||||
|
||||
export async function getOrCreateProjectBranchSync(c: any, workspaceId: string, repoId: string, repoPath: string, intervalMs: number) {
|
||||
return await actorClient(c).projectBranchSync.getOrCreate(projectBranchSyncKey(workspaceId, repoId), {
|
||||
createWithInput: {
|
||||
|
|
@ -85,10 +86,6 @@ export async function getOrCreateTaskSandbox(c: any, workspaceId: string, sandbo
|
|||
});
|
||||
}
|
||||
|
||||
export function selfProjectPrSync(c: any) {
|
||||
return actorClient(c).projectPrSync.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfProjectBranchSync(c: any) {
|
||||
return actorClient(c).projectBranchSync.getForId(c.actorId);
|
||||
}
|
||||
|
|
@ -112,3 +109,7 @@ export function selfProject(c: any) {
|
|||
export function selfAuthUser(c: any) {
|
||||
return actorClient(c).authUser.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfGithubData(c: any) {
|
||||
return actorClient(c).githubData.getForId(c.actorId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { authUser } from "./auth-user/index.js";
|
||||
import { setup } from "rivetkit";
|
||||
import { githubData } from "./github-data/index.js";
|
||||
import { task } from "./task/index.js";
|
||||
import { history } from "./history/index.js";
|
||||
import { projectBranchSync } from "./project-branch-sync/index.js";
|
||||
import { projectPrSync } from "./project-pr-sync/index.js";
|
||||
import { project } from "./project/index.js";
|
||||
import { taskSandbox } from "./sandbox/index.js";
|
||||
import { workspace } from "./workspace/index.js";
|
||||
|
|
@ -28,7 +28,7 @@ export const registry = setup({
|
|||
task,
|
||||
taskSandbox,
|
||||
history,
|
||||
projectPrSync,
|
||||
githubData,
|
||||
projectBranchSync,
|
||||
},
|
||||
});
|
||||
|
|
@ -36,11 +36,11 @@ export const registry = setup({
|
|||
export * from "./context.js";
|
||||
export * from "./events.js";
|
||||
export * from "./auth-user/index.js";
|
||||
export * from "./github-data/index.js";
|
||||
export * from "./task/index.js";
|
||||
export * from "./history/index.js";
|
||||
export * from "./keys.js";
|
||||
export * from "./project-branch-sync/index.js";
|
||||
export * from "./project-pr-sync/index.js";
|
||||
export * from "./project/index.js";
|
||||
export * from "./sandbox/index.js";
|
||||
export * from "./workspace/index.js";
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ export function historyKey(workspaceId: string, repoId: string): ActorKey {
|
|||
return ["ws", workspaceId, "project", repoId, "history"];
|
||||
}
|
||||
|
||||
export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "pr-sync"];
|
||||
export function githubDataKey(workspaceId: string): ActorKey {
|
||||
return ["ws", workspaceId, "github-data"];
|
||||
}
|
||||
|
||||
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getProject, selfProjectPrSync } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
|
||||
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
|
||||
export interface ProjectPrSyncInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoPath: string;
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface SetIntervalCommand {
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface ProjectPrSyncState extends PollingControlState {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
const CONTROL = {
|
||||
start: "project.pr_sync.control.start",
|
||||
stop: "project.pr_sync.control.stop",
|
||||
setInterval: "project.pr_sync.control.set_interval",
|
||||
force: "project.pr_sync.control.force",
|
||||
} as const;
|
||||
|
||||
async function pollPrs(c: { state: ProjectPrSyncState }): Promise<void> {
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
||||
const items = await driver.github.listPullRequests(c.state.repoPath, { githubToken: auth?.githubToken ?? null });
|
||||
const parent = getProject(c, c.state.workspaceId, c.state.repoId);
|
||||
await parent.applyPrSyncResult({ items, at: Date.now() });
|
||||
}
|
||||
|
||||
export const projectPrSync = actor({
|
||||
queues: {
|
||||
[CONTROL.start]: queue(),
|
||||
[CONTROL.stop]: queue(),
|
||||
[CONTROL.setInterval]: queue(),
|
||||
[CONTROL.force]: queue(),
|
||||
},
|
||||
options: {
|
||||
name: "Project PR Sync",
|
||||
icon: "code-merge",
|
||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true,
|
||||
},
|
||||
createState: (_c, input: ProjectPrSyncInput): ProjectPrSyncState => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
repoPath: input.repoPath,
|
||||
intervalMs: input.intervalMs,
|
||||
running: true,
|
||||
}),
|
||||
actions: {
|
||||
async start(c): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async stop(c): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async force(c): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
||||
},
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await runWorkflowPollingLoop<ProjectPrSyncState>(ctx, {
|
||||
loopName: "project-pr-sync-loop",
|
||||
control: CONTROL,
|
||||
onPoll: async (loopCtx) => {
|
||||
try {
|
||||
await pollPrs(loopCtx);
|
||||
} catch (error) {
|
||||
logActorWarning("project-pr-sync", "poll failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
stack: resolveErrorStack(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
@ -4,13 +4,13 @@ import { and, desc, eq, isNotNull, ne } from "drizzle-orm";
|
|||
import { Loop } from "rivetkit/workflow";
|
||||
import type { AgentType, TaskRecord, TaskSummary, ProviderId, RepoOverview, RepoStackAction, RepoStackActionResult } from "@sandbox-agent/foundry-shared";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getTask, getOrCreateTask, getOrCreateHistory, getOrCreateProjectBranchSync, getOrCreateProjectPrSync, selfProject } from "../handles.js";
|
||||
import { getGithubData, getTask, getOrCreateTask, getOrCreateHistory, getOrCreateProjectBranchSync, selfProject } from "../handles.js";
|
||||
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { foundryRepoClonePath } from "../../services/foundry-paths.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { withRepoGitLock } from "../../services/repo-git-lock.js";
|
||||
import { branches, taskIndex, prCache, repoActionJobs, repoMeta } from "./db/schema.js";
|
||||
import { branches, taskIndex, repoActionJobs, repoMeta } from "./db/schema.js";
|
||||
import { deriveFallbackTitle } from "../../services/create-flow.js";
|
||||
import { normalizeBaseBranchName } from "../../integrations/git-spice/index.js";
|
||||
import { sortBranchesForOverview } from "./stack-model.js";
|
||||
|
|
@ -55,22 +55,6 @@ interface GetPullRequestForBranchCommand {
|
|||
branchName: string;
|
||||
}
|
||||
|
||||
interface PrSyncResult {
|
||||
items: Array<{
|
||||
number: number;
|
||||
headRefName: string;
|
||||
state: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
author?: string;
|
||||
isDraft?: boolean;
|
||||
ciStatus?: string | null;
|
||||
reviewStatus?: string | null;
|
||||
reviewer?: string | null;
|
||||
}>;
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface BranchSyncResult {
|
||||
items: Array<{
|
||||
branchName: string;
|
||||
|
|
@ -99,7 +83,6 @@ const PROJECT_QUEUE_NAMES = [
|
|||
"project.command.createTask",
|
||||
"project.command.registerTaskBranch",
|
||||
"project.command.runRepoStackAction",
|
||||
"project.command.applyPrSyncResult",
|
||||
"project.command.applyBranchSyncResult",
|
||||
] as const;
|
||||
|
||||
|
|
@ -125,18 +108,9 @@ async function ensureProjectSyncActors(c: any, localPath: string): Promise<void>
|
|||
return;
|
||||
}
|
||||
|
||||
const prSync = await getOrCreateProjectPrSync(c, c.state.workspaceId, c.state.repoId, localPath, 30_000);
|
||||
const branchSync = await getOrCreateProjectBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000);
|
||||
c.state.syncActorsStarted = true;
|
||||
|
||||
void prSync.start().catch((error: unknown) => {
|
||||
logActorWarning("project.sync", "starting pr sync actor failed", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
});
|
||||
|
||||
void branchSync.start().catch((error: unknown) => {
|
||||
logActorWarning("project.sync", "starting branch sync actor failed", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
|
|
@ -352,9 +326,6 @@ async function ensureTaskIndexHydratedForRead(c: any): Promise<void> {
|
|||
}
|
||||
|
||||
async function forceProjectSync(c: any, localPath: string): Promise<void> {
|
||||
const prSync = await getOrCreateProjectPrSync(c, c.state.workspaceId, c.state.repoId, localPath, 30_000);
|
||||
await prSync.force();
|
||||
|
||||
const branchSync = await getOrCreateProjectBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000);
|
||||
await branchSync.force();
|
||||
}
|
||||
|
|
@ -377,17 +348,10 @@ async function enrichTaskRecord(c: any, record: TaskRecord): Promise<TaskRecord>
|
|||
|
||||
const pr =
|
||||
branchName != null
|
||||
? await c.db
|
||||
.select({
|
||||
prUrl: prCache.prUrl,
|
||||
prAuthor: prCache.prAuthor,
|
||||
ciStatus: prCache.ciStatus,
|
||||
reviewStatus: prCache.reviewStatus,
|
||||
reviewer: prCache.reviewer,
|
||||
})
|
||||
.from(prCache)
|
||||
.where(eq(prCache.branchName, branchName))
|
||||
.get()
|
||||
? await getGithubData(c, c.state.workspaceId)
|
||||
.listPullRequestsForRepository({ repoId: c.state.repoId })
|
||||
.then((rows: any[]) => rows.find((row) => row.headRefName === branchName) ?? null)
|
||||
.catch(() => null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
|
@ -396,11 +360,11 @@ async function enrichTaskRecord(c: any, record: TaskRecord): Promise<TaskRecord>
|
|||
hasUnpushed: br?.hasUnpushed != null ? String(br.hasUnpushed) : null,
|
||||
conflictsWithMain: br?.conflictsWithMain != null ? String(br.conflictsWithMain) : null,
|
||||
parentBranch: br?.parentBranch ?? null,
|
||||
prUrl: pr?.prUrl ?? null,
|
||||
prAuthor: pr?.prAuthor ?? null,
|
||||
ciStatus: pr?.ciStatus ?? null,
|
||||
reviewStatus: pr?.reviewStatus ?? null,
|
||||
reviewer: pr?.reviewer ?? null,
|
||||
prUrl: pr?.url ?? null,
|
||||
prAuthor: pr?.authorLogin ?? null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: pr?.authorLogin ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -458,11 +422,6 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
|
|||
const taskId = randomUUID();
|
||||
|
||||
if (onBranch) {
|
||||
const branchRow = await c.db.select({ branchName: branches.branchName }).from(branches).where(eq(branches.branchName, onBranch)).get();
|
||||
if (!branchRow) {
|
||||
throw new Error(`Branch not found in repo snapshot: ${onBranch}`);
|
||||
}
|
||||
|
||||
await registerTaskBranchMutation(c, {
|
||||
taskId,
|
||||
branchName: onBranch,
|
||||
|
|
@ -810,82 +769,6 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
|
|||
};
|
||||
}
|
||||
|
||||
async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<void> {
|
||||
await c.db.delete(prCache).run();
|
||||
|
||||
for (const item of body.items) {
|
||||
await c.db
|
||||
.insert(prCache)
|
||||
.values({
|
||||
branchName: item.headRefName,
|
||||
prNumber: item.number,
|
||||
state: item.state,
|
||||
title: item.title,
|
||||
prUrl: item.url ?? null,
|
||||
prAuthor: item.author ?? null,
|
||||
isDraft: item.isDraft ? 1 : 0,
|
||||
ciStatus: item.ciStatus ?? null,
|
||||
reviewStatus: item.reviewStatus ?? null,
|
||||
reviewer: item.reviewer ?? null,
|
||||
fetchedAt: body.at,
|
||||
updatedAt: body.at,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: prCache.branchName,
|
||||
set: {
|
||||
prNumber: item.number,
|
||||
state: item.state,
|
||||
title: item.title,
|
||||
prUrl: item.url ?? null,
|
||||
prAuthor: item.author ?? null,
|
||||
isDraft: item.isDraft ? 1 : 0,
|
||||
ciStatus: item.ciStatus ?? null,
|
||||
reviewStatus: item.reviewStatus ?? null,
|
||||
reviewer: item.reviewer ?? null,
|
||||
fetchedAt: body.at,
|
||||
updatedAt: body.at,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
for (const item of body.items) {
|
||||
if (item.state !== "MERGED" && item.state !== "CLOSED") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.branchName, item.headRefName)).get();
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const h = getTask(c, c.state.workspaceId, c.state.repoId, row.taskId);
|
||||
await h.archive({ reason: `PR ${item.state.toLowerCase()}` });
|
||||
} catch (error) {
|
||||
if (isStaleTaskReferenceError(error)) {
|
||||
await deleteStaleTaskIndexRow(c, row.taskId);
|
||||
logActorWarning("project", "pruned stale task index row during PR close archive", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
taskId: row.taskId,
|
||||
branchName: item.headRefName,
|
||||
prState: item.state,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
logActorWarning("project", "failed to auto-archive task after PR close", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
taskId: row.taskId,
|
||||
branchName: item.headRefName,
|
||||
prState: item.state,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applyBranchSyncResultMutation(c: any, body: BranchSyncResult): Promise<void> {
|
||||
const incoming = new Set(body.items.map((item) => item.branchName));
|
||||
const reservedRows = await c.db.select({ branchName: taskIndex.branchName }).from(taskIndex).where(isNotNull(taskIndex.branchName)).all();
|
||||
|
|
@ -953,69 +836,77 @@ export async function runProjectWorkflow(ctx: any): Promise<void> {
|
|||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.ensure") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-ensure",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => ensureProjectMutation(loopCtx, msg.body as EnsureProjectCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
try {
|
||||
if (msg.name === "project.command.ensure") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-ensure",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => ensureProjectMutation(loopCtx, msg.body as EnsureProjectCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.hydrateTaskIndex") {
|
||||
await loopCtx.step("project-hydrate-task-index", async () => hydrateTaskIndexMutation(loopCtx, msg.body as HydrateTaskIndexCommand));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "project.command.hydrateTaskIndex") {
|
||||
await loopCtx.step("project-hydrate-task-index", async () => hydrateTaskIndexMutation(loopCtx, msg.body as HydrateTaskIndexCommand));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.createTask") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-create-task",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "project.command.createTask") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-create-task",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.registerTaskBranch") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-register-task-branch",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => registerTaskBranchMutation(loopCtx, msg.body as RegisterTaskBranchCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "project.command.registerTaskBranch") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-register-task-branch",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => registerTaskBranchMutation(loopCtx, msg.body as RegisterTaskBranchCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.runRepoStackAction") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-run-repo-stack-action",
|
||||
timeout: 12 * 60_000,
|
||||
run: async () => runRepoStackActionMutation(loopCtx, msg.body as RunRepoStackActionCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "project.command.runRepoStackAction") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-run-repo-stack-action",
|
||||
timeout: 12 * 60_000,
|
||||
run: async () => runRepoStackActionMutation(loopCtx, msg.body as RunRepoStackActionCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.applyPrSyncResult") {
|
||||
await loopCtx.step({
|
||||
name: "project-apply-pr-sync-result",
|
||||
timeout: 60_000,
|
||||
run: async () => applyPrSyncResultMutation(loopCtx, msg.body as PrSyncResult),
|
||||
if (msg.name === "project.command.applyBranchSyncResult") {
|
||||
await loopCtx.step({
|
||||
name: "project-apply-branch-sync-result",
|
||||
timeout: 60_000,
|
||||
run: async () => applyBranchSyncResultMutation(loopCtx, msg.body as BranchSyncResult),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = resolveErrorMessage(error);
|
||||
logActorWarning("project", "project workflow command failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
queueName: msg.name,
|
||||
error: message,
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.applyBranchSyncResult") {
|
||||
await loopCtx.step({
|
||||
name: "project-apply-branch-sync-result",
|
||||
timeout: 60_000,
|
||||
run: async () => applyBranchSyncResultMutation(loopCtx, msg.body as BranchSyncResult),
|
||||
await msg.complete({ error: message }).catch((completeError: unknown) => {
|
||||
logActorWarning("project", "project workflow failed completing error response", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
queueName: msg.name,
|
||||
error: resolveErrorMessage(completeError),
|
||||
});
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
return Loop.continue(undefined);
|
||||
|
|
@ -1219,19 +1110,9 @@ export const projectActions = {
|
|||
}
|
||||
}
|
||||
|
||||
const prRows = await c.db
|
||||
.select({
|
||||
branchName: prCache.branchName,
|
||||
prNumber: prCache.prNumber,
|
||||
prState: prCache.state,
|
||||
prUrl: prCache.prUrl,
|
||||
ciStatus: prCache.ciStatus,
|
||||
reviewStatus: prCache.reviewStatus,
|
||||
reviewer: prCache.reviewer,
|
||||
})
|
||||
.from(prCache)
|
||||
.all();
|
||||
const prByBranch = new Map(prRows.map((row) => [row.branchName, row]));
|
||||
const githubData = getGithubData(c, c.state.workspaceId);
|
||||
const prRows = await githubData.listPullRequestsForRepository({ repoId: c.state.repoId }).catch(() => []);
|
||||
const prByBranch = new Map(prRows.map((row) => [row.headRefName, row]));
|
||||
|
||||
const combinedRows = sortBranchesForOverview(
|
||||
branchRowsRaw.map((row) => ({
|
||||
|
|
@ -1258,12 +1139,12 @@ export const projectActions = {
|
|||
taskId: taskMeta?.taskId ?? null,
|
||||
taskTitle: taskMeta?.title ?? null,
|
||||
taskStatus: taskMeta?.status ?? null,
|
||||
prNumber: pr?.prNumber ?? null,
|
||||
prState: pr?.prState ?? null,
|
||||
prUrl: pr?.prUrl ?? null,
|
||||
ciStatus: pr?.ciStatus ?? null,
|
||||
reviewStatus: pr?.reviewStatus ?? null,
|
||||
reviewer: pr?.reviewer ?? null,
|
||||
prNumber: pr?.number ?? null,
|
||||
prState: pr?.state ?? null,
|
||||
prUrl: pr?.url ?? null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: pr?.authorLogin ?? null,
|
||||
firstSeenAt: row.firstSeenAt ?? null,
|
||||
lastSeenAt: row.lastSeenAt ?? null,
|
||||
updatedAt: Math.max(row.updatedAt, taskMeta?.updatedAt ?? 0),
|
||||
|
|
@ -1271,7 +1152,7 @@ export const projectActions = {
|
|||
});
|
||||
|
||||
const latestBranchSync = await c.db.select({ updatedAt: branches.updatedAt }).from(branches).orderBy(desc(branches.updatedAt)).limit(1).get();
|
||||
const latestPrSync = await c.db.select({ updatedAt: prCache.updatedAt }).from(prCache).orderBy(desc(prCache.updatedAt)).limit(1).get();
|
||||
const githubSummary = await githubData.getSummary().catch(() => null);
|
||||
|
||||
return {
|
||||
workspaceId: c.state.workspaceId,
|
||||
|
|
@ -1281,9 +1162,9 @@ export const projectActions = {
|
|||
stackAvailable,
|
||||
fetchedAt: now,
|
||||
branchSyncAt: latestBranchSync?.updatedAt ?? null,
|
||||
prSyncAt: latestPrSync?.updatedAt ?? null,
|
||||
prSyncAt: githubSummary?.lastSyncAt ?? null,
|
||||
branchSyncStatus: latestBranchSync ? "synced" : "pending",
|
||||
prSyncStatus: latestPrSync ? "synced" : "pending",
|
||||
prSyncStatus: githubSummary?.syncStatus ?? "pending",
|
||||
repoActionJobs: await listRepoActionJobRows(c),
|
||||
branches: branchRows,
|
||||
};
|
||||
|
|
@ -1294,24 +1175,11 @@ export const projectActions = {
|
|||
if (!branchName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pr = await c.db
|
||||
.select({
|
||||
prNumber: prCache.prNumber,
|
||||
prState: prCache.state,
|
||||
})
|
||||
.from(prCache)
|
||||
.where(eq(prCache.branchName, branchName))
|
||||
.get();
|
||||
|
||||
if (!pr?.prNumber) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
number: pr.prNumber,
|
||||
status: pr.prState === "draft" ? "draft" : "ready",
|
||||
};
|
||||
const githubData = getGithubData(c, c.state.workspaceId);
|
||||
return await githubData.getPullRequestForBranch({
|
||||
repoId: c.state.repoId,
|
||||
branchName,
|
||||
});
|
||||
},
|
||||
|
||||
async runRepoStackAction(c: any, cmd: RunRepoStackActionCommand): Promise<RepoStackActionResult> {
|
||||
|
|
@ -1353,14 +1221,6 @@ export const projectActions = {
|
|||
};
|
||||
},
|
||||
|
||||
async applyPrSyncResult(c: any, body: PrSyncResult): Promise<void> {
|
||||
const self = selfProject(c);
|
||||
await self.send(projectWorkflowQueueName("project.command.applyPrSyncResult"), body, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async applyBranchSyncResult(c: any, body: BranchSyncResult): Promise<void> {
|
||||
const self = selfProject(c);
|
||||
await self.send(projectWorkflowQueueName("project.command.applyBranchSyncResult"), body, {
|
||||
|
|
|
|||
|
|
@ -29,21 +29,6 @@ export default {
|
|||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`pr_cache\` (
|
||||
\`branch_name\` text PRIMARY KEY NOT NULL,
|
||||
\`pr_number\` integer NOT NULL,
|
||||
\`state\` text NOT NULL,
|
||||
\`title\` text NOT NULL,
|
||||
\`pr_url\` text,
|
||||
\`pr_author\` text,
|
||||
\`is_draft\` integer DEFAULT 0 NOT NULL,
|
||||
\`ci_status\` text,
|
||||
\`review_status\` text,
|
||||
\`reviewer\` text,
|
||||
\`fetched_at\` integer,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`repo_meta\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`remote_url\` text NOT NULL,
|
||||
|
|
|
|||
|
|
@ -21,21 +21,6 @@ export const repoMeta = sqliteTable("repo_meta", {
|
|||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const prCache = sqliteTable("pr_cache", {
|
||||
branchName: text("branch_name").notNull().primaryKey(),
|
||||
prNumber: integer("pr_number").notNull(),
|
||||
state: text("state").notNull(),
|
||||
title: text("title").notNull(),
|
||||
prUrl: text("pr_url"),
|
||||
prAuthor: text("pr_author"),
|
||||
isDraft: integer("is_draft").notNull().default(0),
|
||||
ciStatus: text("ci_status"),
|
||||
reviewStatus: text("review_status"),
|
||||
reviewer: text("reviewer"),
|
||||
fetchedAt: integer("fetched_at"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const taskIndex = sqliteTable("task_index", {
|
||||
taskId: text("task_id").notNull().primaryKey(),
|
||||
branchName: text("branch_name"),
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@ interface TaskWorkbenchSendMessageCommand {
|
|||
attachments: Array<any>;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSendMessageActionInput extends TaskWorkbenchSendMessageInput {
|
||||
waitForCompletion?: boolean;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchCreateSessionCommand {
|
||||
model?: string;
|
||||
}
|
||||
|
|
@ -317,9 +321,9 @@ export const task = actor({
|
|||
);
|
||||
},
|
||||
|
||||
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageInput): Promise<void> {
|
||||
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageActionInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
const result = await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.send_message"),
|
||||
{
|
||||
sessionId: input.tabId,
|
||||
|
|
@ -327,9 +331,13 @@ export const task = actor({
|
|||
attachments: input.attachments,
|
||||
} satisfies TaskWorkbenchSendMessageCommand,
|
||||
{
|
||||
wait: false,
|
||||
wait: input.waitForCompletion === true,
|
||||
...(input.waitForCompletion === true ? { timeout: 10 * 60_000 } : {}),
|
||||
},
|
||||
);
|
||||
if (input.waitForCompletion === true) {
|
||||
expectQueueResponse(result);
|
||||
}
|
||||
},
|
||||
|
||||
async stopWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -149,6 +149,23 @@ export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: numbe
|
|||
return Boolean(meta.thinkingSinceMs);
|
||||
}
|
||||
|
||||
export function shouldRecreateSessionForModelChange(meta: {
|
||||
status: "pending_provision" | "pending_session_create" | "ready" | "error";
|
||||
sandboxSessionId?: string | null;
|
||||
created?: boolean;
|
||||
transcript?: Array<any>;
|
||||
}): boolean {
|
||||
if (meta.status !== "ready" || !meta.sandboxSessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (meta.created) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !Array.isArray(meta.transcript) || meta.transcript.length === 0;
|
||||
}
|
||||
|
||||
async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise<Array<any>> {
|
||||
await ensureWorkbenchSessionTable(c);
|
||||
const rows = await c.db.select().from(taskWorkbenchSessions).orderBy(asc(taskWorkbenchSessions.createdAt)).all();
|
||||
|
|
@ -290,6 +307,24 @@ async function requireReadySessionMeta(c: any, tabId: string): Promise<any> {
|
|||
return meta;
|
||||
}
|
||||
|
||||
async function ensureReadySessionMeta(c: any, tabId: string): Promise<any> {
|
||||
const meta = await readSessionMeta(c, tabId);
|
||||
if (!meta) {
|
||||
throw new Error(`Unknown workbench tab: ${tabId}`);
|
||||
}
|
||||
|
||||
if (meta.status === "ready" && meta.sandboxSessionId) {
|
||||
return meta;
|
||||
}
|
||||
|
||||
if (meta.status === "error") {
|
||||
throw new Error(meta.errorMessage ?? "This workbench tab failed to prepare");
|
||||
}
|
||||
|
||||
await ensureWorkbenchSession(c, tabId);
|
||||
return await requireReadySessionMeta(c, tabId);
|
||||
}
|
||||
|
||||
function shellFragment(parts: string[]): string {
|
||||
return parts.join(" && ");
|
||||
}
|
||||
|
|
@ -662,6 +697,23 @@ async function enqueueWorkbenchRefresh(
|
|||
await self.send(command, body, { wait: false });
|
||||
}
|
||||
|
||||
async function enqueueWorkbenchEnsureSession(c: any, tabId: string): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
"task.command.workbench.ensure_session",
|
||||
{
|
||||
tabId,
|
||||
},
|
||||
{
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function pendingWorkbenchSessionStatus(record: any): "pending_provision" | "pending_session_create" {
|
||||
return record.activeSandboxId ? "pending_session_create" : "pending_provision";
|
||||
}
|
||||
|
||||
async function maybeScheduleWorkbenchRefreshes(c: any, record: any, sessions: Array<any>): Promise<void> {
|
||||
const gitState = await readCachedGitState(c);
|
||||
if (record.activeSandboxId && !gitState.updatedAt) {
|
||||
|
|
@ -721,7 +773,7 @@ export async function ensureWorkbenchSeeded(c: any): Promise<any> {
|
|||
}
|
||||
|
||||
function buildSessionSummary(record: any, meta: any): any {
|
||||
const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null);
|
||||
const derivedSandboxSessionId = meta.status === "ready" ? (meta.sandboxSessionId ?? null) : null;
|
||||
const sessionStatus =
|
||||
meta.status === "pending_provision" || meta.status === "pending_session_create"
|
||||
? meta.status
|
||||
|
|
@ -991,12 +1043,12 @@ export async function createWorkbenchSession(c: any, model?: string): Promise<{
|
|||
await ensureSessionMeta(c, {
|
||||
tabId,
|
||||
model: model ?? defaultModelForAgent(record.agentType),
|
||||
sandboxSessionId: tabId,
|
||||
status: record.activeSandboxId ? "pending_session_create" : "pending_provision",
|
||||
sandboxSessionId: null,
|
||||
status: pendingWorkbenchSessionStatus(record),
|
||||
created: false,
|
||||
});
|
||||
await ensureWorkbenchSession(c, tabId, model);
|
||||
await broadcastTaskUpdate(c, { sessionId: tabId });
|
||||
await enqueueWorkbenchEnsureSession(c, tabId);
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
|
|
@ -1099,14 +1151,60 @@ export async function updateWorkbenchDraft(c: any, sessionId: string, text: stri
|
|||
}
|
||||
|
||||
export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise<void> {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
const meta = await readSessionMeta(c, sessionId);
|
||||
if (!meta || meta.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.model === model) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
let nextMeta = await updateSessionMeta(c, sessionId, {
|
||||
model,
|
||||
});
|
||||
let shouldEnsure = nextMeta.status === "pending_provision" || nextMeta.status === "pending_session_create" || nextMeta.status === "error";
|
||||
|
||||
if (shouldRecreateSessionForModelChange(nextMeta)) {
|
||||
const sandbox = getTaskSandbox(c, c.state.workspaceId, stableSandboxId(c));
|
||||
await sandbox.destroySession(nextMeta.sandboxSessionId);
|
||||
nextMeta = await updateSessionMeta(c, sessionId, {
|
||||
sandboxSessionId: null,
|
||||
status: pendingWorkbenchSessionStatus(record),
|
||||
errorMessage: null,
|
||||
transcriptJson: "[]",
|
||||
transcriptUpdatedAt: null,
|
||||
thinkingSinceMs: null,
|
||||
});
|
||||
shouldEnsure = true;
|
||||
} else if (nextMeta.status === "ready" && nextMeta.sandboxSessionId) {
|
||||
const sandbox = getTaskSandbox(c, c.state.workspaceId, stableSandboxId(c));
|
||||
if (typeof sandbox.rawSendSessionMethod === "function") {
|
||||
try {
|
||||
await sandbox.rawSendSessionMethod(nextMeta.sandboxSessionId, "session/set_config_option", {
|
||||
configId: "model",
|
||||
value: model,
|
||||
});
|
||||
} catch {
|
||||
// Some agents do not allow live model updates. Preserve the new preference in metadata.
|
||||
}
|
||||
}
|
||||
} else if (nextMeta.status !== "ready") {
|
||||
nextMeta = await updateSessionMeta(c, sessionId, {
|
||||
status: pendingWorkbenchSessionStatus(record),
|
||||
errorMessage: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldEnsure) {
|
||||
await enqueueWorkbenchEnsureSession(c, sessionId);
|
||||
}
|
||||
await broadcastTaskUpdate(c, { sessionId });
|
||||
}
|
||||
|
||||
export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
|
||||
const meta = await requireReadySessionMeta(c, sessionId);
|
||||
const meta = await ensureReadySessionMeta(c, sessionId);
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const runtime = await getTaskSandboxRuntime(c, record);
|
||||
await ensureSandboxRepo(c, runtime.sandbox, record);
|
||||
|
|
|
|||
|
|
@ -186,12 +186,16 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
|||
},
|
||||
|
||||
"task.command.workbench.send_message": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-send-message",
|
||||
timeout: 10 * 60_000,
|
||||
run: async () => sendWorkbenchMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
try {
|
||||
await loopCtx.step({
|
||||
name: "workbench-send-message",
|
||||
timeout: 10 * 60_000,
|
||||
run: async () => sendWorkbenchMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
} catch (error) {
|
||||
await msg.complete({ error: resolveErrorMessage(error) });
|
||||
}
|
||||
},
|
||||
|
||||
"task.command.workbench.stop_session": async (loopCtx, msg) => {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import type {
|
|||
TaskWorkbenchSendMessageInput,
|
||||
TaskWorkbenchTabInput,
|
||||
TaskWorkbenchUpdateDraftInput,
|
||||
WorkbenchOpenPrSummary,
|
||||
WorkbenchRepoSummary,
|
||||
WorkbenchSessionSummary,
|
||||
WorkbenchTaskSummary,
|
||||
|
|
@ -36,12 +37,12 @@ import type {
|
|||
WorkspaceUseInput,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
|
||||
import { getGithubData, getOrCreateGithubData, getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { availableSandboxProviderIds, defaultSandboxProviderId } from "../../sandbox-config.js";
|
||||
import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { taskLookup, repos, providerProfiles, taskSummaries } from "./db/schema.js";
|
||||
import { organizationProfile, taskLookup, repos, providerProfiles, taskSummaries } from "./db/schema.js";
|
||||
import { agentTypeForModel } from "../task/workbench.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { workspaceAppActions } from "./app-shell.js";
|
||||
|
|
@ -85,6 +86,8 @@ export function workspaceWorkflowQueueName(name: WorkspaceQueueName): WorkspaceQ
|
|||
return name;
|
||||
}
|
||||
|
||||
const ORGANIZATION_PROFILE_ROW_ID = "profile";
|
||||
|
||||
function assertWorkspace(c: { state: WorkspaceState }, workspaceId: string): void {
|
||||
if (workspaceId !== c.state.workspaceId) {
|
||||
throw new Error(`Workspace actor mismatch: actor=${c.state.workspaceId} command=${workspaceId}`);
|
||||
|
|
@ -203,6 +206,14 @@ function taskSummaryFromRow(row: any): WorkbenchTaskSummary {
|
|||
};
|
||||
}
|
||||
|
||||
async function listOpenPullRequestsSnapshot(c: any, taskRows: WorkbenchTaskSummary[]): Promise<WorkbenchOpenPrSummary[]> {
|
||||
const githubData = getGithubData(c, c.state.workspaceId);
|
||||
const openPullRequests = await githubData.listOpenPullRequests({}).catch(() => []);
|
||||
const claimedBranches = new Set(taskRows.filter((task) => task.branch).map((task) => `${task.repoId}:${task.branch}`));
|
||||
|
||||
return openPullRequests.filter((pullRequest: WorkbenchOpenPrSummary) => !claimedBranches.has(`${pullRequest.repoId}:${pullRequest.headRefName}`));
|
||||
}
|
||||
|
||||
async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySnapshot> {
|
||||
const repoRows = await c.db
|
||||
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })
|
||||
|
|
@ -252,6 +263,7 @@ async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySna
|
|||
workspaceId: c.state.workspaceId,
|
||||
repos: repoRows.map((row) => buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
|
||||
taskSummaries: taskRows,
|
||||
openPullRequests: await listOpenPullRequestsSnapshot(c, taskRows),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -280,8 +292,8 @@ async function waitForWorkbenchTaskReady(task: any, timeoutMs = 5 * 60_000): Pro
|
|||
|
||||
/**
|
||||
* Reads the workspace sidebar snapshot from the workspace actor's local SQLite
|
||||
* only. Task actors push summary updates into `task_summaries`, so clients do
|
||||
* not need this action to fan out to every child actor on the hot read path.
|
||||
* plus the org-scoped GitHub actor for open PRs. Task actors still push
|
||||
* summary updates into `task_summaries`, so the hot read path stays bounded.
|
||||
*/
|
||||
async function getWorkspaceSummarySnapshot(c: any): Promise<WorkspaceSummarySnapshot> {
|
||||
const repoRows = await c.db
|
||||
|
|
@ -300,6 +312,7 @@ async function getWorkspaceSummarySnapshot(c: any): Promise<WorkspaceSummarySnap
|
|||
workspaceId: c.state.workspaceId,
|
||||
repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
|
||||
taskSummaries: summaries,
|
||||
openPullRequests: await listOpenPullRequestsSnapshot(c, summaries),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -463,58 +476,74 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
|
|||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "workspace.command.addRepo") {
|
||||
const result = await loopCtx.step({
|
||||
name: "workspace-add-repo",
|
||||
timeout: 60_000,
|
||||
run: async () => addRepoMutation(loopCtx, msg.body as AddRepoInput),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
try {
|
||||
if (msg.name === "workspace.command.addRepo") {
|
||||
const result = await loopCtx.step({
|
||||
name: "workspace-add-repo",
|
||||
timeout: 60_000,
|
||||
run: async () => addRepoMutation(loopCtx, msg.body as AddRepoInput),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "workspace.command.createTask") {
|
||||
const result = await loopCtx.step({
|
||||
name: "workspace-create-task",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "workspace.command.createTask") {
|
||||
const result = await loopCtx.step({
|
||||
name: "workspace-create-task",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "workspace.command.refreshProviderProfiles") {
|
||||
await loopCtx.step("workspace-refresh-provider-profiles", async () =>
|
||||
refreshProviderProfilesMutation(loopCtx, msg.body as RefreshProviderProfilesCommand),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "workspace.command.refreshProviderProfiles") {
|
||||
await loopCtx.step("workspace-refresh-provider-profiles", async () =>
|
||||
refreshProviderProfilesMutation(loopCtx, msg.body as RefreshProviderProfilesCommand),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "workspace.command.syncGithubSession") {
|
||||
await loopCtx.step({
|
||||
name: "workspace-sync-github-session",
|
||||
timeout: 60_000,
|
||||
run: async () => {
|
||||
const { syncGithubOrganizations } = await import("./app-shell.js");
|
||||
await syncGithubOrganizations(loopCtx, msg.body as { sessionId: string; accessToken: string });
|
||||
},
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "workspace.command.syncGithubSession") {
|
||||
await loopCtx.step({
|
||||
name: "workspace-sync-github-session",
|
||||
timeout: 60_000,
|
||||
run: async () => {
|
||||
const { syncGithubOrganizations } = await import("./app-shell.js");
|
||||
await syncGithubOrganizations(loopCtx, msg.body as { sessionId: string; accessToken: string });
|
||||
},
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "workspace.command.syncGithubOrganizationRepos") {
|
||||
await loopCtx.step({
|
||||
name: "workspace-sync-github-organization-repos",
|
||||
timeout: 60_000,
|
||||
run: async () => {
|
||||
const { syncGithubOrganizationRepos } = await import("./app-shell.js");
|
||||
await syncGithubOrganizationRepos(loopCtx, msg.body as { sessionId: string; organizationId: string });
|
||||
},
|
||||
if (msg.name === "workspace.command.syncGithubOrganizationRepos") {
|
||||
await loopCtx.step({
|
||||
name: "workspace-sync-github-organization-repos",
|
||||
timeout: 60_000,
|
||||
run: async () => {
|
||||
const { syncGithubOrganizationRepos } = await import("./app-shell.js");
|
||||
await syncGithubOrganizationRepos(loopCtx, msg.body as { sessionId: string; organizationId: string });
|
||||
},
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = resolveErrorMessage(error);
|
||||
logActorWarning("workspace", "workspace workflow command failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
queueName: msg.name,
|
||||
error: message,
|
||||
});
|
||||
await msg.complete({ error: message }).catch((completeError: unknown) => {
|
||||
logActorWarning("workspace", "workspace workflow failed completing error response", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
queueName: msg.name,
|
||||
error: resolveErrorMessage(completeError),
|
||||
});
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
return Loop.continue(undefined);
|
||||
|
|
@ -604,6 +633,175 @@ export const workspaceActions = {
|
|||
c.broadcast("workspaceUpdated", { type: "taskRemoved", taskId: input.taskId } satisfies WorkspaceEvent);
|
||||
},
|
||||
|
||||
async findTaskForGithubBranch(c: any, input: { repoId: string; branchName: string }): Promise<{ taskId: string | null }> {
|
||||
const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.repoId)).all();
|
||||
const existing = summaries.find((summary) => summary.branch === input.branchName);
|
||||
return { taskId: existing?.taskId ?? null };
|
||||
},
|
||||
|
||||
async refreshTaskSummaryForGithubBranch(c: any, input: { repoId: string; branchName: string }): Promise<void> {
|
||||
const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.repoId)).all();
|
||||
const matches = summaries.filter((summary) => summary.branch === input.branchName);
|
||||
|
||||
for (const summary of matches) {
|
||||
try {
|
||||
const task = getTask(c, c.state.workspaceId, input.repoId, summary.taskId);
|
||||
await workspaceActions.applyTaskSummaryUpdate(c, {
|
||||
taskSummary: await task.getTaskSummary({}),
|
||||
});
|
||||
} catch (error) {
|
||||
logActorWarning("workspace", "failed refreshing task summary for GitHub branch", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: input.repoId,
|
||||
branchName: input.branchName,
|
||||
taskId: summary.taskId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async applyOpenPullRequestUpdate(c: any, input: { pullRequest: WorkbenchOpenPrSummary }): Promise<void> {
|
||||
const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.pullRequest.repoId)).all();
|
||||
if (summaries.some((summary) => summary.branch === input.pullRequest.headRefName)) {
|
||||
return;
|
||||
}
|
||||
c.broadcast("workspaceUpdated", { type: "pullRequestUpdated", pullRequest: input.pullRequest } satisfies WorkspaceEvent);
|
||||
},
|
||||
|
||||
async removeOpenPullRequest(c: any, input: { prId: string }): Promise<void> {
|
||||
c.broadcast("workspaceUpdated", { type: "pullRequestRemoved", prId: input.prId } satisfies WorkspaceEvent);
|
||||
},
|
||||
|
||||
async applyGithubRepositoryProjection(c: any, input: { repoId: string; remoteUrl: string }): Promise<void> {
|
||||
const now = Date.now();
|
||||
const existing = await c.db.select({ repoId: repos.repoId }).from(repos).where(eq(repos.repoId, input.repoId)).get();
|
||||
await c.db
|
||||
.insert(repos)
|
||||
.values({
|
||||
repoId: input.repoId,
|
||||
remoteUrl: input.remoteUrl,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: repos.repoId,
|
||||
set: {
|
||||
remoteUrl: input.remoteUrl,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
await broadcastRepoSummary(c, existing ? "repoUpdated" : "repoAdded", {
|
||||
repoId: input.repoId,
|
||||
remoteUrl: input.remoteUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
|
||||
async applyGithubDataProjection(
|
||||
c: any,
|
||||
input: {
|
||||
connectedAccount: string;
|
||||
installationStatus: string;
|
||||
installationId: number | null;
|
||||
syncStatus: string;
|
||||
lastSyncLabel: string;
|
||||
lastSyncAt: number | null;
|
||||
repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const existingRepos = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt }).from(repos).all();
|
||||
const existingById = new Map(existingRepos.map((repo) => [repo.repoId, repo]));
|
||||
const nextRepoIds = new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
for (const repository of input.repositories) {
|
||||
const repoId = repoIdFromRemote(repository.cloneUrl);
|
||||
nextRepoIds.add(repoId);
|
||||
await c.db
|
||||
.insert(repos)
|
||||
.values({
|
||||
repoId,
|
||||
remoteUrl: repository.cloneUrl,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: repos.repoId,
|
||||
set: {
|
||||
remoteUrl: repository.cloneUrl,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
await broadcastRepoSummary(c, existingById.has(repoId) ? "repoUpdated" : "repoAdded", {
|
||||
repoId,
|
||||
remoteUrl: repository.cloneUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
for (const repo of existingRepos) {
|
||||
if (nextRepoIds.has(repo.repoId)) {
|
||||
continue;
|
||||
}
|
||||
await c.db.delete(repos).where(eq(repos.repoId, repo.repoId)).run();
|
||||
c.broadcast("workspaceUpdated", { type: "repoRemoved", repoId: repo.repoId } satisfies WorkspaceEvent);
|
||||
}
|
||||
|
||||
const profile = await c.db
|
||||
.select({ id: organizationProfile.id })
|
||||
.from(organizationProfile)
|
||||
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
|
||||
.get();
|
||||
if (profile) {
|
||||
await c.db
|
||||
.update(organizationProfile)
|
||||
.set({
|
||||
githubConnectedAccount: input.connectedAccount,
|
||||
githubInstallationStatus: input.installationStatus,
|
||||
githubSyncStatus: input.syncStatus,
|
||||
githubInstallationId: input.installationId,
|
||||
githubLastSyncLabel: input.lastSyncLabel,
|
||||
githubLastSyncAt: input.lastSyncAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
|
||||
.run();
|
||||
}
|
||||
},
|
||||
|
||||
async recordGithubWebhookReceipt(
|
||||
c: any,
|
||||
input: {
|
||||
workspaceId: string;
|
||||
event: string;
|
||||
action?: string | null;
|
||||
receivedAt?: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
|
||||
const profile = await c.db
|
||||
.select({ id: organizationProfile.id })
|
||||
.from(organizationProfile)
|
||||
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
|
||||
.get();
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
await c.db
|
||||
.update(organizationProfile)
|
||||
.set({
|
||||
githubLastWebhookAt: input.receivedAt ?? Date.now(),
|
||||
githubLastWebhookEvent: input.action ? `${input.event}.${input.action}` : input.event,
|
||||
})
|
||||
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
|
||||
.run();
|
||||
},
|
||||
|
||||
async getWorkspaceSummary(c: any, input: WorkspaceUseInput): Promise<WorkspaceSummarySnapshot> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
return await getWorkspaceSummarySnapshot(c);
|
||||
|
|
@ -620,7 +818,7 @@ export const workspaceActions = {
|
|||
repoId: input.repoId,
|
||||
task: input.task,
|
||||
...(input.title ? { explicitTitle: input.title } : {}),
|
||||
...(input.branch ? { explicitBranchName: input.branch } : {}),
|
||||
...(input.onBranch ? { onBranch: input.onBranch } : input.branch ? { explicitBranchName: input.branch } : {}),
|
||||
...(input.model ? { agentType: agentTypeForModel(input.model) } : {}),
|
||||
});
|
||||
const task = await requireWorkbenchTask(c, created.taskId);
|
||||
|
|
@ -634,6 +832,10 @@ export const workspaceActions = {
|
|||
tabId: session.tabId,
|
||||
text: input.task,
|
||||
attachments: [],
|
||||
waitForCompletion: true,
|
||||
});
|
||||
await task.getSessionDetail({
|
||||
sessionId: session.tabId,
|
||||
});
|
||||
return {
|
||||
taskId: created.taskId,
|
||||
|
|
@ -706,6 +908,22 @@ export const workspaceActions = {
|
|||
await task.revertWorkbenchFile(input);
|
||||
},
|
||||
|
||||
async reloadGithubOrganization(c: any): Promise<void> {
|
||||
await getOrCreateGithubData(c, c.state.workspaceId).reloadOrganization({});
|
||||
},
|
||||
|
||||
async reloadGithubPullRequests(c: any): Promise<void> {
|
||||
await getOrCreateGithubData(c, c.state.workspaceId).reloadAllPullRequests({});
|
||||
},
|
||||
|
||||
async reloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
|
||||
await getOrCreateGithubData(c, c.state.workspaceId).reloadRepository(input);
|
||||
},
|
||||
|
||||
async reloadGithubPullRequest(c: any, input: { repoId: string; prNumber: number }): Promise<void> {
|
||||
await getOrCreateGithubData(c, c.state.workspaceId).reloadPullRequest(input);
|
||||
},
|
||||
|
||||
async listTasks(c: any, input: ListTasksInput): Promise<TaskSummary[]> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import type {
|
|||
UpdateFoundryOrganizationProfileInput,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getOrCreateWorkspace, selfWorkspace } from "../handles.js";
|
||||
import { getOrCreateGithubData, getOrCreateWorkspace, selfWorkspace } from "../handles.js";
|
||||
import { GitHubAppError } from "../../services/app-github.js";
|
||||
import { getBetterAuthService } from "../../services/better-auth.js";
|
||||
import { repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js";
|
||||
|
|
@ -601,40 +601,19 @@ export async function syncGithubOrganizationRepos(c: any, input: { sessionId: st
|
|||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
requireEligibleOrganization(session, input.organizationId);
|
||||
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const workspace = await getOrCreateWorkspace(c, input.organizationId);
|
||||
const organization = await getOrganizationState(workspace);
|
||||
const githubData = await getOrCreateGithubData(c, input.organizationId);
|
||||
|
||||
try {
|
||||
let repositories;
|
||||
let installationStatus = organization.snapshot.github.installationStatus;
|
||||
|
||||
if (organization.snapshot.kind === "personal") {
|
||||
repositories = await appShell.github.listUserRepositories(session.githubAccessToken);
|
||||
installationStatus = "connected";
|
||||
} else if (organization.githubInstallationId) {
|
||||
try {
|
||||
repositories = await appShell.github.listInstallationRepositories(organization.githubInstallationId);
|
||||
} catch (error) {
|
||||
if (!(error instanceof GitHubAppError) || (error.status !== 403 && error.status !== 404)) {
|
||||
throw error;
|
||||
}
|
||||
repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) =>
|
||||
repository.fullName.startsWith(`${organization.githubLogin}/`),
|
||||
);
|
||||
installationStatus = "reconnect_required";
|
||||
}
|
||||
} else {
|
||||
repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) =>
|
||||
repository.fullName.startsWith(`${organization.githubLogin}/`),
|
||||
);
|
||||
installationStatus = "reconnect_required";
|
||||
}
|
||||
|
||||
await workspace.applyOrganizationSyncCompleted({
|
||||
repositories,
|
||||
installationStatus,
|
||||
lastSyncLabel: repositories.length > 0 ? "Synced just now" : "No repositories available",
|
||||
await githubData.fullSync({
|
||||
accessToken: session.githubAccessToken,
|
||||
connectedAccount: organization.snapshot.github.connectedAccount,
|
||||
installationId: organization.githubInstallationId,
|
||||
installationStatus: organization.snapshot.github.installationStatus,
|
||||
githubLogin: organization.githubLogin,
|
||||
kind: organization.snapshot.kind,
|
||||
label: "Importing repository catalog...",
|
||||
});
|
||||
|
||||
// Broadcast updated app snapshot so connected clients see the new repos
|
||||
|
|
@ -759,6 +738,8 @@ async function buildOrganizationStateFromRow(c: any, row: any, startedAt: number
|
|||
importedRepoCount: repoCatalog.length,
|
||||
lastSyncLabel: row.githubLastSyncLabel,
|
||||
lastSyncAt: row.githubLastSyncAt ?? null,
|
||||
lastWebhookAt: row.githubLastWebhookAt ?? null,
|
||||
lastWebhookEvent: row.githubLastWebhookEvent ?? "",
|
||||
},
|
||||
billing: {
|
||||
planId: row.billingPlanId,
|
||||
|
|
@ -1433,8 +1414,8 @@ export const workspaceAppActions = {
|
|||
const { appShell } = getActorRuntimeContext();
|
||||
const { event, body } = appShell.github.verifyWebhookEvent(input.payload, input.signatureHeader, input.eventHeader);
|
||||
|
||||
const accountLogin = body.installation?.account?.login;
|
||||
const accountType = body.installation?.account?.type;
|
||||
const accountLogin = body.installation?.account?.login ?? body.repository?.owner?.login ?? body.organization?.login ?? null;
|
||||
const accountType = body.installation?.account?.type ?? (body.organization?.login ? "Organization" : null);
|
||||
if (!accountLogin) {
|
||||
githubWebhookLogger.info(
|
||||
{
|
||||
|
|
@ -1449,6 +1430,15 @@ export const workspaceAppActions = {
|
|||
|
||||
const kind: FoundryOrganization["kind"] = accountType === "User" ? "personal" : "organization";
|
||||
const organizationId = organizationWorkspaceId(kind, accountLogin);
|
||||
const receivedAt = Date.now();
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
await workspace.recordGithubWebhookReceipt({
|
||||
workspaceId: organizationId,
|
||||
event,
|
||||
action: body.action ?? null,
|
||||
receivedAt,
|
||||
});
|
||||
const githubData = await getOrCreateGithubData(c, organizationId);
|
||||
|
||||
if (event === "installation" && (body.action === "created" || body.action === "deleted" || body.action === "suspend" || body.action === "unsuspend")) {
|
||||
githubWebhookLogger.info(
|
||||
|
|
@ -1461,12 +1451,36 @@ export const workspaceAppActions = {
|
|||
"installation_event",
|
||||
);
|
||||
if (body.action === "deleted") {
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
await workspace.applyGithubInstallationRemoved({});
|
||||
await githubData.clearState({
|
||||
connectedAccount: accountLogin,
|
||||
installationStatus: "install_required",
|
||||
installationId: null,
|
||||
label: "GitHub App installation removed",
|
||||
});
|
||||
} else if (body.action === "created") {
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
await workspace.applyGithubInstallationCreated({
|
||||
installationId: body.installation?.id ?? 0,
|
||||
await githubData.fullSync({
|
||||
connectedAccount: accountLogin,
|
||||
installationStatus: "connected",
|
||||
installationId: body.installation?.id ?? null,
|
||||
githubLogin: accountLogin,
|
||||
kind,
|
||||
label: "Syncing GitHub data from installation webhook...",
|
||||
});
|
||||
} else if (body.action === "suspend") {
|
||||
await githubData.clearState({
|
||||
connectedAccount: accountLogin,
|
||||
installationStatus: "reconnect_required",
|
||||
installationId: body.installation?.id ?? null,
|
||||
label: "GitHub App installation suspended",
|
||||
});
|
||||
} else if (body.action === "unsuspend") {
|
||||
await githubData.fullSync({
|
||||
connectedAccount: accountLogin,
|
||||
installationStatus: "connected",
|
||||
installationId: body.installation?.id ?? null,
|
||||
githubLogin: accountLogin,
|
||||
kind,
|
||||
label: "Resyncing GitHub data after unsuspend...",
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
|
|
@ -1484,13 +1498,13 @@ export const workspaceAppActions = {
|
|||
},
|
||||
"repository_membership_changed",
|
||||
);
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
await workspace.applyGithubRepositoryChanges({
|
||||
added: (body.repositories_added ?? []).map((r) => ({
|
||||
fullName: r.full_name,
|
||||
private: r.private,
|
||||
})),
|
||||
removed: (body.repositories_removed ?? []).map((r) => r.full_name),
|
||||
await githubData.fullSync({
|
||||
connectedAccount: accountLogin,
|
||||
installationStatus: "connected",
|
||||
installationId: body.installation?.id ?? null,
|
||||
githubLogin: accountLogin,
|
||||
kind,
|
||||
label: "Resyncing GitHub data after repository access change...",
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -1518,7 +1532,30 @@ export const workspaceAppActions = {
|
|||
},
|
||||
"repository_event",
|
||||
);
|
||||
// TODO: Dispatch to GitHubStateActor / downstream actors
|
||||
if (event === "pull_request" && body.repository?.clone_url && body.pull_request) {
|
||||
await githubData.handlePullRequestWebhook({
|
||||
connectedAccount: accountLogin,
|
||||
installationStatus: "connected",
|
||||
installationId: body.installation?.id ?? null,
|
||||
repository: {
|
||||
fullName: body.repository.full_name,
|
||||
cloneUrl: body.repository.clone_url,
|
||||
private: Boolean(body.repository.private),
|
||||
},
|
||||
pullRequest: {
|
||||
number: body.pull_request.number,
|
||||
title: body.pull_request.title ?? "",
|
||||
body: body.pull_request.body ?? null,
|
||||
state: body.pull_request.state ?? "open",
|
||||
url: body.pull_request.html_url ?? `https://github.com/${body.repository.full_name}/pull/${body.pull_request.number}`,
|
||||
headRefName: body.pull_request.head?.ref ?? "",
|
||||
baseRefName: body.pull_request.base?.ref ?? "",
|
||||
authorLogin: body.pull_request.user?.login ?? null,
|
||||
isDraft: Boolean(body.pull_request.draft),
|
||||
merged: Boolean(body.pull_request.merged),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ CREATE TABLE `organization_profile` (
|
|||
`github_installation_id` integer,
|
||||
`github_last_sync_label` text NOT NULL,
|
||||
`github_last_sync_at` integer,
|
||||
`github_last_webhook_at` integer,
|
||||
`github_last_webhook_event` text,
|
||||
`stripe_customer_id` text,
|
||||
`stripe_subscription_id` text,
|
||||
`stripe_price_id` text,
|
||||
|
|
|
|||
|
|
@ -359,6 +359,20 @@
|
|||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_last_webhook_at": {
|
||||
"name": "github_last_webhook_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_last_webhook_event": {
|
||||
"name": "github_last_webhook_event",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripe_customer_id": {
|
||||
"name": "stripe_customer_id",
|
||||
"type": "text",
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ CREATE TABLE \`organization_profile\` (
|
|||
\`github_installation_id\` integer,
|
||||
\`github_last_sync_label\` text NOT NULL,
|
||||
\`github_last_sync_at\` integer,
|
||||
\`github_last_webhook_at\` integer,
|
||||
\`github_last_webhook_event\` text,
|
||||
\`stripe_customer_id\` text,
|
||||
\`stripe_subscription_id\` text,
|
||||
\`stripe_price_id\` text,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ export const organizationProfile = sqliteTable("organization_profile", {
|
|||
githubInstallationId: integer("github_installation_id"),
|
||||
githubLastSyncLabel: text("github_last_sync_label").notNull(),
|
||||
githubLastSyncAt: integer("github_last_sync_at"),
|
||||
githubLastWebhookAt: integer("github_last_webhook_at"),
|
||||
githubLastWebhookEvent: text("github_last_webhook_event"),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||
stripePriceId: text("stripe_price_id"),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,30 @@ export interface GitHubRepositoryRecord {
|
|||
private: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubMemberRecord {
|
||||
id: string;
|
||||
login: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
role: string | null;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface GitHubPullRequestRecord {
|
||||
repoFullName: string;
|
||||
cloneUrl: string;
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
authorLogin: string | null;
|
||||
isDraft: boolean;
|
||||
merged: boolean;
|
||||
}
|
||||
|
||||
interface GitHubTokenResponse {
|
||||
access_token?: string;
|
||||
scope?: string;
|
||||
|
|
@ -58,11 +82,23 @@ const githubOAuthLogger = logger.child({
|
|||
|
||||
export interface GitHubWebhookEvent {
|
||||
action?: string;
|
||||
organization?: { login?: string; id?: number };
|
||||
installation?: { id: number; account?: { login?: string; type?: string; id?: number } | null };
|
||||
repositories_added?: Array<{ id: number; full_name: string; private: boolean }>;
|
||||
repositories_removed?: Array<{ id: number; full_name: string }>;
|
||||
repository?: { id: number; full_name: string; clone_url?: string; private?: boolean; owner?: { login?: string } };
|
||||
pull_request?: { number: number; title?: string; state?: string; head?: { ref?: string }; base?: { ref?: string } };
|
||||
pull_request?: {
|
||||
number: number;
|
||||
title?: string;
|
||||
body?: string | null;
|
||||
state?: string;
|
||||
html_url?: string;
|
||||
draft?: boolean;
|
||||
merged?: boolean;
|
||||
user?: { login?: string } | null;
|
||||
head?: { ref?: string };
|
||||
base?: { ref?: string };
|
||||
};
|
||||
sender?: { login?: string; id?: number };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
@ -329,6 +365,130 @@ export class GitHubAppClient {
|
|||
}));
|
||||
}
|
||||
|
||||
async getUserRepository(accessToken: string, fullName: string): Promise<GitHubRepositoryRecord | null> {
|
||||
try {
|
||||
const repository = await this.requestJson<{
|
||||
full_name: string;
|
||||
clone_url: string;
|
||||
private: boolean;
|
||||
}>(`/repos/${fullName}`, accessToken);
|
||||
return {
|
||||
fullName: repository.full_name,
|
||||
cloneUrl: repository.clone_url,
|
||||
private: repository.private,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof GitHubAppError && error.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getInstallationRepository(installationId: number, fullName: string): Promise<GitHubRepositoryRecord | null> {
|
||||
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||
return await this.getUserRepository(accessToken, fullName);
|
||||
}
|
||||
|
||||
async listOrganizationMembers(accessToken: string, organizationLogin: string): Promise<GitHubMemberRecord[]> {
|
||||
const members = await this.paginate<{
|
||||
id: number;
|
||||
login: string;
|
||||
role?: string | null;
|
||||
}>(`/orgs/${organizationLogin}/members?per_page=100&role=all`, accessToken);
|
||||
|
||||
const detailedMembers = await Promise.all(
|
||||
members.map(async (member) => {
|
||||
try {
|
||||
const detail = await this.requestJson<{
|
||||
id: number;
|
||||
login: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
}>(`/users/${member.login}`, accessToken);
|
||||
return {
|
||||
id: String(detail.id),
|
||||
login: detail.login,
|
||||
name: detail.name?.trim() || detail.login,
|
||||
email: detail.email ?? null,
|
||||
role: member.role ?? null,
|
||||
state: "active",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: String(member.id),
|
||||
login: member.login,
|
||||
name: member.login,
|
||||
email: null,
|
||||
role: member.role ?? null,
|
||||
state: "active",
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return detailedMembers;
|
||||
}
|
||||
|
||||
async listInstallationMembers(installationId: number, organizationLogin: string): Promise<GitHubMemberRecord[]> {
|
||||
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||
return await this.listOrganizationMembers(accessToken, organizationLogin);
|
||||
}
|
||||
|
||||
async listPullRequestsForUserRepositories(accessToken: string, repositories: GitHubRepositoryRecord[]): Promise<GitHubPullRequestRecord[]> {
|
||||
return (await Promise.all(repositories.map((repository) => this.listRepositoryPullRequests(accessToken, repository.fullName, repository.cloneUrl)))).flat();
|
||||
}
|
||||
|
||||
async listInstallationPullRequestsForRepositories(installationId: number, repositories: GitHubRepositoryRecord[]): Promise<GitHubPullRequestRecord[]> {
|
||||
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||
return await this.listPullRequestsForUserRepositories(accessToken, repositories);
|
||||
}
|
||||
|
||||
async getUserPullRequest(accessToken: string, fullName: string, prNumber: number): Promise<GitHubPullRequestRecord | null> {
|
||||
try {
|
||||
const pullRequest = await this.requestJson<{
|
||||
number: number;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
state: string;
|
||||
html_url: string;
|
||||
draft?: boolean;
|
||||
merged?: boolean;
|
||||
user?: { login?: string } | null;
|
||||
head?: { ref?: string } | null;
|
||||
base?: { ref?: string } | null;
|
||||
}>(`/repos/${fullName}/pulls/${prNumber}`, accessToken);
|
||||
const repository = await this.getUserRepository(accessToken, fullName);
|
||||
if (!repository) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
repoFullName: fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
number: pullRequest.number,
|
||||
title: pullRequest.title,
|
||||
body: pullRequest.body ?? null,
|
||||
state: pullRequest.state,
|
||||
url: pullRequest.html_url,
|
||||
headRefName: pullRequest.head?.ref?.trim() ?? "",
|
||||
baseRefName: pullRequest.base?.ref?.trim() ?? "",
|
||||
authorLogin: pullRequest.user?.login?.trim() ?? null,
|
||||
isDraft: Boolean(pullRequest.draft),
|
||||
merged: Boolean(pullRequest.merged),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof GitHubAppError && error.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getInstallationPullRequest(installationId: number, fullName: string, prNumber: number): Promise<GitHubPullRequestRecord | null> {
|
||||
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||
return await this.getUserPullRequest(accessToken, fullName, prNumber);
|
||||
}
|
||||
|
||||
async buildInstallationUrl(organizationLogin: string, state: string): Promise<string> {
|
||||
if (!this.isAppConfigured()) {
|
||||
throw new GitHubAppError("GitHub App is not configured", 500);
|
||||
|
|
@ -437,6 +597,36 @@ export class GitHubAppClient {
|
|||
return payload as T;
|
||||
}
|
||||
|
||||
private async listRepositoryPullRequests(accessToken: string, fullName: string, cloneUrl: string): Promise<GitHubPullRequestRecord[]> {
|
||||
const pullRequests = await this.paginate<{
|
||||
number: number;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
state: string;
|
||||
html_url: string;
|
||||
draft?: boolean;
|
||||
merged?: boolean;
|
||||
user?: { login?: string } | null;
|
||||
head?: { ref?: string } | null;
|
||||
base?: { ref?: string } | null;
|
||||
}>(`/repos/${fullName}/pulls?state=open&per_page=100&sort=updated&direction=desc`, accessToken);
|
||||
|
||||
return pullRequests.map((pullRequest) => ({
|
||||
repoFullName: fullName,
|
||||
cloneUrl,
|
||||
number: pullRequest.number,
|
||||
title: pullRequest.title,
|
||||
body: pullRequest.body ?? null,
|
||||
state: pullRequest.state,
|
||||
url: pullRequest.html_url,
|
||||
headRefName: pullRequest.head?.ref?.trim() ?? "",
|
||||
baseRefName: pullRequest.base?.ref?.trim() ?? "",
|
||||
authorLogin: pullRequest.user?.login?.trim() ?? null,
|
||||
isDraft: Boolean(pullRequest.draft),
|
||||
merged: Boolean(pullRequest.merged),
|
||||
}));
|
||||
}
|
||||
|
||||
private async paginate<T>(path: string, accessToken: string): Promise<T[]> {
|
||||
let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
const items: T[] = [];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ export function expectQueueResponse<T>(result: QueueSendResult | void): T {
|
|||
if (!result || result.status === "timedOut") {
|
||||
throw new Error("Queue command timed out");
|
||||
}
|
||||
if (
|
||||
result.response &&
|
||||
typeof result.response === "object" &&
|
||||
"error" in result.response &&
|
||||
typeof (result.response as { error?: unknown }).error === "string"
|
||||
) {
|
||||
throw new Error((result.response as { error: string }).error);
|
||||
}
|
||||
return result.response as T;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { taskKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, taskSandboxKey, workspaceKey } from "../src/actors/keys.js";
|
||||
import { githubDataKey, historyKey, projectBranchSyncKey, projectKey, taskKey, taskSandboxKey, workspaceKey } from "../src/actors/keys.js";
|
||||
|
||||
describe("actor keys", () => {
|
||||
it("prefixes every key with workspace namespace", () => {
|
||||
|
|
@ -9,7 +9,7 @@ describe("actor keys", () => {
|
|||
taskKey("default", "repo", "task"),
|
||||
taskSandboxKey("default", "sbx"),
|
||||
historyKey("default", "repo"),
|
||||
projectPrSyncKey("default", "repo"),
|
||||
githubDataKey("default"),
|
||||
projectBranchSyncKey("default", "repo"),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { shouldMarkSessionUnreadForStatus } from "../src/actors/task/workbench.js";
|
||||
import { shouldMarkSessionUnreadForStatus, shouldRecreateSessionForModelChange } from "../src/actors/task/workbench.js";
|
||||
|
||||
describe("workbench unread status transitions", () => {
|
||||
it("marks unread when a running session first becomes idle", () => {
|
||||
|
|
@ -14,3 +14,46 @@ describe("workbench unread status transitions", () => {
|
|||
expect(shouldMarkSessionUnreadForStatus({ thinkingSinceMs: Date.now() - 1_000 }, "running")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workbench model changes", () => {
|
||||
it("recreates an unused ready session so the selected model takes effect", () => {
|
||||
expect(
|
||||
shouldRecreateSessionForModelChange({
|
||||
status: "ready",
|
||||
sandboxSessionId: "session-1",
|
||||
created: false,
|
||||
transcript: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not recreate a session once the conversation has started", () => {
|
||||
expect(
|
||||
shouldRecreateSessionForModelChange({
|
||||
status: "ready",
|
||||
sandboxSessionId: "session-1",
|
||||
created: true,
|
||||
transcript: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not recreate pending or anonymous sessions", () => {
|
||||
expect(
|
||||
shouldRecreateSessionForModelChange({
|
||||
status: "pending_session_create",
|
||||
sandboxSessionId: "session-1",
|
||||
created: false,
|
||||
transcript: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRecreateSessionForModelChange({
|
||||
status: "ready",
|
||||
sandboxSessionId: null,
|
||||
created: false,
|
||||
transcript: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -112,6 +112,10 @@ interface WorkspaceHandle {
|
|||
closeWorkbenchSession(input: TaskWorkbenchTabInput): Promise<void>;
|
||||
publishWorkbenchPr(input: TaskWorkbenchSelectInput): Promise<void>;
|
||||
revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>;
|
||||
reloadGithubOrganization(): Promise<void>;
|
||||
reloadGithubPullRequests(): Promise<void>;
|
||||
reloadGithubRepository(input: { repoId: string }): Promise<void>;
|
||||
reloadGithubPullRequest(input: { repoId: string; prNumber: number }): Promise<void>;
|
||||
}
|
||||
|
||||
interface AppWorkspaceHandle {
|
||||
|
|
@ -296,6 +300,10 @@ export interface BackendClient {
|
|||
closeWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise<void>;
|
||||
publishWorkbenchPr(workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void>;
|
||||
revertWorkbenchFile(workspaceId: string, input: TaskWorkbenchDiffInput): Promise<void>;
|
||||
reloadGithubOrganization(workspaceId: string): Promise<void>;
|
||||
reloadGithubPullRequests(workspaceId: string): Promise<void>;
|
||||
reloadGithubRepository(workspaceId: string, repoId: string): Promise<void>;
|
||||
reloadGithubPullRequest(workspaceId: string, repoId: string, prNumber: number): Promise<void>;
|
||||
health(): Promise<{ ok: true }>;
|
||||
useWorkspace(workspaceId: string): Promise<{ workspaceId: string }>;
|
||||
starSandboxAgentRepo(workspaceId: string): Promise<StarSandboxAgentRepoResult>;
|
||||
|
|
@ -1182,6 +1190,22 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
await (await workspace(workspaceId)).revertWorkbenchFile(input);
|
||||
},
|
||||
|
||||
async reloadGithubOrganization(workspaceId: string): Promise<void> {
|
||||
await (await workspace(workspaceId)).reloadGithubOrganization();
|
||||
},
|
||||
|
||||
async reloadGithubPullRequests(workspaceId: string): Promise<void> {
|
||||
await (await workspace(workspaceId)).reloadGithubPullRequests();
|
||||
},
|
||||
|
||||
async reloadGithubRepository(workspaceId: string, repoId: string): Promise<void> {
|
||||
await (await workspace(workspaceId)).reloadGithubRepository({ repoId });
|
||||
},
|
||||
|
||||
async reloadGithubPullRequest(workspaceId: string, repoId: string, prNumber: number): Promise<void> {
|
||||
await (await workspace(workspaceId)).reloadGithubPullRequest({ repoId, prNumber });
|
||||
},
|
||||
|
||||
async health(): Promise<{ ok: true }> {
|
||||
const workspaceId = options.defaultWorkspaceId;
|
||||
if (!workspaceId) {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ function upsertById<T extends { id: string }>(items: T[], nextItem: T, sort: (le
|
|||
return [...filtered, nextItem].sort(sort);
|
||||
}
|
||||
|
||||
function upsertByPrId<T extends { prId: string }>(items: T[], nextItem: T, sort: (left: T, right: T) => number): T[] {
|
||||
const filtered = items.filter((item) => item.prId !== nextItem.prId);
|
||||
return [...filtered, nextItem].sort(sort);
|
||||
}
|
||||
|
||||
export const topicDefinitions = {
|
||||
app: {
|
||||
key: () => "app",
|
||||
|
|
@ -90,6 +95,16 @@ export const topicDefinitions = {
|
|||
...current,
|
||||
repos: current.repos.filter((repo) => repo.id !== event.repoId),
|
||||
};
|
||||
case "pullRequestUpdated":
|
||||
return {
|
||||
...current,
|
||||
openPullRequests: upsertByPrId(current.openPullRequests, event.pullRequest, (left, right) => right.updatedAtMs - left.updatedAtMs),
|
||||
};
|
||||
case "pullRequestRemoved":
|
||||
return {
|
||||
...current,
|
||||
openPullRequests: current.openPullRequests.filter((pullRequest) => pullRequest.prId !== event.prId),
|
||||
};
|
||||
}
|
||||
},
|
||||
} satisfies TopicDefinition<WorkspaceSummarySnapshot, WorkspaceTopicParams, WorkspaceEvent>,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ export interface MockFoundryGithubState {
|
|||
importedRepoCount: number;
|
||||
lastSyncLabel: string;
|
||||
lastSyncAt: number | null;
|
||||
lastWebhookAt: number | null;
|
||||
lastWebhookEvent: string;
|
||||
}
|
||||
|
||||
export interface MockFoundryOrganizationSettings {
|
||||
|
|
@ -188,6 +190,8 @@ function buildRivetOrganization(): MockFoundryOrganization {
|
|||
importedRepoCount: repos.length,
|
||||
lastSyncLabel: "Synced just now",
|
||||
lastSyncAt: Date.now() - 60_000,
|
||||
lastWebhookAt: Date.now() - 30_000,
|
||||
lastWebhookEvent: "push",
|
||||
},
|
||||
billing: {
|
||||
planId: "team",
|
||||
|
|
@ -267,6 +271,8 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
|||
importedRepoCount: 1,
|
||||
lastSyncLabel: "Synced just now",
|
||||
lastSyncAt: Date.now() - 60_000,
|
||||
lastWebhookAt: Date.now() - 120_000,
|
||||
lastWebhookEvent: "pull_request.opened",
|
||||
},
|
||||
billing: {
|
||||
planId: "free",
|
||||
|
|
@ -301,6 +307,8 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
|||
importedRepoCount: 3,
|
||||
lastSyncLabel: "Waiting for first import",
|
||||
lastSyncAt: null,
|
||||
lastWebhookAt: null,
|
||||
lastWebhookEvent: "",
|
||||
},
|
||||
billing: {
|
||||
planId: "team",
|
||||
|
|
@ -344,6 +352,8 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
|||
importedRepoCount: 1,
|
||||
lastSyncLabel: "Synced yesterday",
|
||||
lastSyncAt: Date.now() - 24 * 60 * 60_000,
|
||||
lastWebhookAt: Date.now() - 3_600_000,
|
||||
lastWebhookEvent: "check_run.completed",
|
||||
},
|
||||
billing: {
|
||||
planId: "free",
|
||||
|
|
@ -397,6 +407,8 @@ function parseStoredSnapshot(): MockFoundryAppSnapshot | null {
|
|||
...organization.github,
|
||||
syncStatus: syncStatusFromLegacy(organization.github?.syncStatus ?? organization.repoImportStatus),
|
||||
lastSyncAt: organization.github?.lastSyncAt ?? null,
|
||||
lastWebhookAt: organization.github?.lastWebhookAt ?? null,
|
||||
lastWebhookEvent: organization.github?.lastWebhookEvent ?? "",
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
|
@ -567,6 +579,8 @@ class MockFoundryAppStore implements MockFoundryAppClient {
|
|||
syncStatus: "synced",
|
||||
lastSyncLabel: "Synced just now",
|
||||
lastSyncAt: Date.now(),
|
||||
lastWebhookAt: Date.now(),
|
||||
lastWebhookEvent: "installation_repositories.added",
|
||||
},
|
||||
}));
|
||||
this.importTimers.delete(organizationId);
|
||||
|
|
|
|||
|
|
@ -249,6 +249,7 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
|||
};
|
||||
}),
|
||||
taskSummaries,
|
||||
openPullRequests: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -763,6 +764,14 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
|||
emitTaskUpdate(input.taskId);
|
||||
},
|
||||
|
||||
async reloadGithubOrganization(): Promise<void> {},
|
||||
|
||||
async reloadGithubPullRequests(): Promise<void> {},
|
||||
|
||||
async reloadGithubRepository(): Promise<void> {},
|
||||
|
||||
async reloadGithubPullRequest(): Promise<void> {},
|
||||
|
||||
async health(): Promise<{ ok: true }> {
|
||||
return { ok: true };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -100,7 +100,8 @@ class RemoteWorkbenchStore implements TaskWorkbenchClient {
|
|||
|
||||
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
|
||||
await this.backend.updateWorkbenchDraft(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
// Skip refresh — the server broadcast will trigger it, and the frontend
|
||||
// holds local draft state to avoid the round-trip overwriting user input.
|
||||
}
|
||||
|
||||
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ function workspaceSnapshot(): WorkspaceSummarySnapshot {
|
|||
sessionsSummary: [],
|
||||
},
|
||||
],
|
||||
openPullRequests: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,10 +71,10 @@ function timeAgo(ts: number | null): string {
|
|||
if (!ts) return "never";
|
||||
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||||
if (seconds < 5) return "now";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
return `${Math.floor(minutes / 60)}h`;
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
return `${Math.floor(minutes / 60)}h ago`;
|
||||
}
|
||||
|
||||
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||
|
|
@ -157,8 +157,11 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
}, [now]);
|
||||
|
||||
const repos = snapshot.repos ?? [];
|
||||
const prCount = (snapshot.tasks ?? []).filter((task) => task.pullRequest != null).length;
|
||||
const focusedTaskStatus = focusedTask?.runtimeStatus ?? focusedTask?.status ?? null;
|
||||
const focusedTaskState = describeTaskState(focusedTaskStatus, focusedTask?.statusMessage ?? null);
|
||||
const lastWebhookAt = organization?.github.lastWebhookAt ?? null;
|
||||
const hasRecentWebhook = lastWebhookAt != null && now - lastWebhookAt < 5 * 60_000;
|
||||
|
||||
const mono = css({
|
||||
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace",
|
||||
|
|
@ -436,8 +439,28 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
<span className={css({ color: t.textPrimary, flex: 1 })}>Sync</span>
|
||||
<span className={`${mono} ${css({ color: syncStatusColor(organization.github.syncStatus, t) })}`}>{organization.github.syncStatus}</span>
|
||||
</div>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: hasRecentWebhook ? t.statusSuccess : t.textMuted,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ color: t.textPrimary, flex: 1 })}>Webhook</span>
|
||||
{lastWebhookAt != null ? (
|
||||
<span className={`${mono} ${css({ color: hasRecentWebhook ? t.textPrimary : t.textMuted })}`}>
|
||||
{organization.github.lastWebhookEvent} · {timeAgo(lastWebhookAt)}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>never received</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
|
||||
<Stat label="repos imported" value={organization.github.importedRepoCount} t={t} css={css} />
|
||||
<Stat label="repos" value={organization.github.importedRepoCount} t={t} css={css} />
|
||||
<Stat label="PRs" value={prCount} t={t} css={css} />
|
||||
</div>
|
||||
{organization.github.connectedAccount && (
|
||||
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{organization.github.connectedAccount}</div>
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ import { useNavigate } from "@tanstack/react-router";
|
|||
import { useStyletron } from "baseui";
|
||||
import {
|
||||
createErrorContext,
|
||||
type FoundryOrganization,
|
||||
type TaskWorkbenchSnapshot,
|
||||
type WorkbenchOpenPrSummary,
|
||||
type WorkbenchSessionSummary,
|
||||
type WorkbenchTaskDetail,
|
||||
type WorkbenchTaskSummary,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||
|
||||
import { PanelLeft, PanelRight } from "lucide-react";
|
||||
import { CircleAlert, PanelLeft, PanelRight } from "lucide-react";
|
||||
import { useFoundryTokens } from "../app/theme";
|
||||
import { logger } from "../logging.js";
|
||||
|
||||
|
|
@ -75,6 +77,59 @@ function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openD
|
|||
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId;
|
||||
}
|
||||
|
||||
function githubInstallationWarningTitle(organization: FoundryOrganization): string {
|
||||
return organization.github.installationStatus === "install_required" ? "GitHub App not installed" : "GitHub App needs reconnection";
|
||||
}
|
||||
|
||||
function githubInstallationWarningDetail(organization: FoundryOrganization): string {
|
||||
const statusDetail = organization.github.lastSyncLabel.trim();
|
||||
const requirementDetail =
|
||||
organization.github.installationStatus === "install_required"
|
||||
? "Webhooks are required for Foundry to function. Repo sync and PR updates will not work until the GitHub App is installed for this workspace."
|
||||
: "Webhook delivery is unavailable. Repo sync and PR updates will not work until the GitHub App is reconnected.";
|
||||
return statusDetail ? `${requirementDetail} ${statusDetail}.` : requirementDetail;
|
||||
}
|
||||
|
||||
function GithubInstallationWarning({
|
||||
organization,
|
||||
css,
|
||||
t,
|
||||
}: {
|
||||
organization: FoundryOrganization;
|
||||
css: ReturnType<typeof useStyletron>[0];
|
||||
t: ReturnType<typeof useFoundryTokens>;
|
||||
}) {
|
||||
if (organization.github.installationStatus === "connected") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
left: "8px",
|
||||
zIndex: 99998,
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: "8px",
|
||||
padding: "10px 12px",
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.statusError}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
maxWidth: "440px",
|
||||
})}
|
||||
>
|
||||
<CircleAlert size={15} color={t.statusError} />
|
||||
<div className={css({ display: "flex", flexDirection: "column", gap: "3px" })}>
|
||||
<div className={css({ fontSize: "11px", fontWeight: 600, color: t.textPrimary })}>{githubInstallationWarningTitle(organization)}</div>
|
||||
<div className={css({ fontSize: "11px", lineHeight: 1.45, color: t.textMuted })}>{githubInstallationWarningDetail(organization)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toLegacyTab(
|
||||
summary: WorkbenchSessionSummary,
|
||||
sessionDetail?: { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] },
|
||||
|
|
@ -125,6 +180,40 @@ function toLegacyTask(
|
|||
};
|
||||
}
|
||||
|
||||
const OPEN_PR_TASK_PREFIX = "pr:";
|
||||
|
||||
function openPrTaskId(prId: string): string {
|
||||
return `${OPEN_PR_TASK_PREFIX}${prId}`;
|
||||
}
|
||||
|
||||
function isOpenPrTaskId(taskId: string): boolean {
|
||||
return taskId.startsWith(OPEN_PR_TASK_PREFIX);
|
||||
}
|
||||
|
||||
function toLegacyOpenPrTask(pullRequest: WorkbenchOpenPrSummary): Task {
|
||||
return {
|
||||
id: openPrTaskId(pullRequest.prId),
|
||||
repoId: pullRequest.repoId,
|
||||
title: pullRequest.title,
|
||||
status: "new",
|
||||
runtimeStatus: undefined,
|
||||
statusMessage: pullRequest.authorLogin ? `@${pullRequest.authorLogin}` : null,
|
||||
repoName: pullRequest.repoFullName,
|
||||
updatedAtMs: pullRequest.updatedAtMs,
|
||||
branch: pullRequest.headRefName,
|
||||
pullRequest: {
|
||||
number: pullRequest.number,
|
||||
status: pullRequest.isDraft ? "draft" : "ready",
|
||||
},
|
||||
tabs: [],
|
||||
fileChanges: [],
|
||||
diffs: {},
|
||||
fileTree: [],
|
||||
minutesUsed: 0,
|
||||
activeSandboxId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionStateMessage(tab: Task["tabs"][number] | null | undefined): string | null {
|
||||
if (!tab) {
|
||||
return null;
|
||||
|
|
@ -153,7 +242,14 @@ function groupProjects(repos: Array<{ id: string; label: string }>, tasks: Task[
|
|||
}
|
||||
|
||||
interface WorkbenchActions {
|
||||
createTask(input: { repoId: string; task: string; title?: string; branch?: string; model?: ModelId }): Promise<{ taskId: string; tabId?: string }>;
|
||||
createTask(input: {
|
||||
repoId: string;
|
||||
task: string;
|
||||
title?: string;
|
||||
branch?: string;
|
||||
onBranch?: string;
|
||||
model?: ModelId;
|
||||
}): Promise<{ taskId: string; tabId?: string }>;
|
||||
markTaskUnread(input: { taskId: string }): Promise<void>;
|
||||
renameTask(input: { taskId: string; value: string }): Promise<void>;
|
||||
renameBranch(input: { taskId: string; value: string }): Promise<void>;
|
||||
|
|
@ -168,6 +264,10 @@ interface WorkbenchActions {
|
|||
closeTab(input: { taskId: string; tabId: string }): Promise<void>;
|
||||
addTab(input: { taskId: string; model?: string }): Promise<{ tabId: string }>;
|
||||
changeModel(input: { taskId: string; tabId: string; model: ModelId }): Promise<void>;
|
||||
reloadGithubOrganization(): Promise<void>;
|
||||
reloadGithubPullRequests(): Promise<void>;
|
||||
reloadGithubRepository(repoId: string): Promise<void>;
|
||||
reloadGithubPullRequest(repoId: string, prNumber: number): Promise<void>;
|
||||
}
|
||||
|
||||
const TranscriptPanel = memo(function TranscriptPanel({
|
||||
|
|
@ -187,6 +287,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onSidebarPeekEnd,
|
||||
rightSidebarCollapsed,
|
||||
onToggleRightSidebar,
|
||||
selectedSessionHydrating = false,
|
||||
onNavigateToUsage,
|
||||
}: {
|
||||
taskWorkbenchClient: WorkbenchActions;
|
||||
|
|
@ -205,6 +306,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onSidebarPeekEnd?: () => void;
|
||||
rightSidebarCollapsed?: boolean;
|
||||
onToggleRightSidebar?: () => void;
|
||||
selectedSessionHydrating?: boolean;
|
||||
onNavigateToUsage?: () => void;
|
||||
}) {
|
||||
const t = useFoundryTokens();
|
||||
|
|
@ -216,6 +318,11 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
const [pendingHistoryTarget, setPendingHistoryTarget] = useState<{ messageId: string; tabId: string } | null>(null);
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
||||
const [timerNowMs, setTimerNowMs] = useState(() => Date.now());
|
||||
const [localDraft, setLocalDraft] = useState("");
|
||||
const [localAttachments, setLocalAttachments] = useState<LineAttachment[]>([]);
|
||||
const lastEditTimeRef = useRef(0);
|
||||
const throttleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingDraftRef = useRef<{ text: string; attachments: LineAttachment[] } | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const messageRefs = useRef(new Map<string, HTMLDivElement>());
|
||||
|
|
@ -235,8 +342,27 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
!!activeAgentTab &&
|
||||
(activeAgentTab.status === "pending_provision" || activeAgentTab.status === "pending_session_create" || activeAgentTab.status === "error") &&
|
||||
activeMessages.length === 0;
|
||||
const draft = promptTab?.draft.text ?? "";
|
||||
const attachments = promptTab?.draft.attachments ?? [];
|
||||
const serverDraft = promptTab?.draft.text ?? "";
|
||||
const serverAttachments = promptTab?.draft.attachments ?? [];
|
||||
|
||||
// Sync server → local only when user hasn't typed recently (3s cooldown)
|
||||
const DRAFT_SYNC_COOLDOWN_MS = 3_000;
|
||||
useEffect(() => {
|
||||
if (Date.now() - lastEditTimeRef.current > DRAFT_SYNC_COOLDOWN_MS) {
|
||||
setLocalDraft(serverDraft);
|
||||
setLocalAttachments(serverAttachments);
|
||||
}
|
||||
}, [serverDraft, serverAttachments]);
|
||||
|
||||
// Reset local draft immediately on tab/task switch
|
||||
useEffect(() => {
|
||||
lastEditTimeRef.current = 0;
|
||||
setLocalDraft(promptTab?.draft.text ?? "");
|
||||
setLocalAttachments(promptTab?.draft.attachments ?? []);
|
||||
}, [promptTab?.id, task.id]);
|
||||
|
||||
const draft = localDraft;
|
||||
const attachments = localAttachments;
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
|
|
@ -343,20 +469,53 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
[editValue, task.id],
|
||||
);
|
||||
|
||||
const DRAFT_THROTTLE_MS = 500;
|
||||
|
||||
const flushDraft = useCallback(
|
||||
(text: string, nextAttachments: LineAttachment[], tabId: string) => {
|
||||
void taskWorkbenchClient.updateDraft({
|
||||
taskId: task.id,
|
||||
tabId,
|
||||
text,
|
||||
attachments: nextAttachments,
|
||||
});
|
||||
},
|
||||
[task.id],
|
||||
);
|
||||
|
||||
// Clean up throttle timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (throttleTimerRef.current) {
|
||||
clearTimeout(throttleTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateDraft = useCallback(
|
||||
(nextText: string, nextAttachments: LineAttachment[]) => {
|
||||
if (!promptTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
void taskWorkbenchClient.updateDraft({
|
||||
taskId: task.id,
|
||||
tabId: promptTab.id,
|
||||
text: nextText,
|
||||
attachments: nextAttachments,
|
||||
});
|
||||
// Update local state immediately for responsive typing
|
||||
lastEditTimeRef.current = Date.now();
|
||||
setLocalDraft(nextText);
|
||||
setLocalAttachments(nextAttachments);
|
||||
|
||||
// Throttle the network call
|
||||
pendingDraftRef.current = { text: nextText, attachments: nextAttachments };
|
||||
if (!throttleTimerRef.current) {
|
||||
throttleTimerRef.current = setTimeout(() => {
|
||||
throttleTimerRef.current = null;
|
||||
if (pendingDraftRef.current) {
|
||||
flushDraft(pendingDraftRef.current.text, pendingDraftRef.current.attachments, promptTab.id);
|
||||
pendingDraftRef.current = null;
|
||||
}
|
||||
}, DRAFT_THROTTLE_MS);
|
||||
}
|
||||
},
|
||||
[task.id, promptTab],
|
||||
[promptTab, flushDraft],
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(() => {
|
||||
|
|
@ -687,6 +846,33 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
</div>
|
||||
</div>
|
||||
</ScrollBody>
|
||||
) : selectedSessionHydrating ? (
|
||||
<ScrollBody>
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "32px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "420px",
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<SpinnerDot size={16} />
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Loading session</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>Fetching the latest transcript for this session.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollBody>
|
||||
) : showPendingSessionState ? (
|
||||
<ScrollBody>
|
||||
<div
|
||||
|
|
@ -1099,12 +1285,25 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
closeTab: (input) => backendClient.closeWorkbenchSession(workspaceId, input),
|
||||
addTab: (input) => backendClient.createWorkbenchSession(workspaceId, input),
|
||||
changeModel: (input) => backendClient.changeWorkbenchModel(workspaceId, input),
|
||||
reloadGithubOrganization: () => backendClient.reloadGithubOrganization(workspaceId),
|
||||
reloadGithubPullRequests: () => backendClient.reloadGithubPullRequests(workspaceId),
|
||||
reloadGithubRepository: (repoId) => backendClient.reloadGithubRepository(workspaceId, repoId),
|
||||
reloadGithubPullRequest: (repoId, prNumber) => backendClient.reloadGithubPullRequest(workspaceId, repoId, prNumber),
|
||||
}),
|
||||
[workspaceId],
|
||||
);
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
const workspaceRepos = workspaceState.data?.repos ?? [];
|
||||
const taskSummaries = workspaceState.data?.taskSummaries ?? [];
|
||||
const openPullRequests = workspaceState.data?.openPullRequests ?? [];
|
||||
const openPullRequestsByTaskId = useMemo(
|
||||
() => new Map(openPullRequests.map((pullRequest) => [openPrTaskId(pullRequest.prId), pullRequest])),
|
||||
[openPullRequests],
|
||||
);
|
||||
const selectedOpenPullRequest = useMemo(
|
||||
() => (selectedTaskId ? (openPullRequestsByTaskId.get(selectedTaskId) ?? null) : null),
|
||||
[openPullRequestsByTaskId, selectedTaskId],
|
||||
);
|
||||
const selectedTaskSummary = useMemo(
|
||||
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
|
||||
[selectedTaskId, taskSummaries],
|
||||
|
|
@ -1169,10 +1368,12 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
}
|
||||
}
|
||||
|
||||
return taskSummaries.map((summary) =>
|
||||
const legacyTasks = taskSummaries.map((summary) =>
|
||||
summary.id === selectedTaskSummary?.id ? toLegacyTask(summary, taskState.data, sessionCache) : toLegacyTask(summary),
|
||||
);
|
||||
}, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, workspaceId]);
|
||||
const legacyOpenPrs = openPullRequests.map((pullRequest) => toLegacyOpenPrTask(pullRequest));
|
||||
return [...legacyTasks, ...legacyOpenPrs].sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||
}, [openPullRequests, selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, workspaceId]);
|
||||
const rawProjects = useMemo(() => groupProjects(workspaceRepos, tasks), [tasks, workspaceRepos]);
|
||||
const appSnapshot = useMockAppSnapshot();
|
||||
const activeOrg = activeMockOrganization(appSnapshot);
|
||||
|
|
@ -1200,9 +1401,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
const leftWidthRef = useRef(leftWidth);
|
||||
const rightWidthRef = useRef(rightWidth);
|
||||
const autoCreatingSessionForTaskRef = useRef<Set<string>>(new Set());
|
||||
const resolvingOpenPullRequestsRef = useRef<Set<string>>(new Set());
|
||||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(true);
|
||||
const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false);
|
||||
const [materializingOpenPrId, setMaterializingOpenPrId] = useState<string | null>(null);
|
||||
const showDevPanel = useDevPanel();
|
||||
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
@ -1268,13 +1471,81 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
startRightRef.current = rightWidthRef.current;
|
||||
}, []);
|
||||
|
||||
const activeTask = useMemo(() => tasks.find((task) => task.id === selectedTaskId) ?? tasks[0] ?? null, [tasks, selectedTaskId]);
|
||||
const activeTask = useMemo(() => {
|
||||
const realTasks = tasks.filter((task) => !isOpenPrTaskId(task.id));
|
||||
if (selectedOpenPullRequest) {
|
||||
return null;
|
||||
}
|
||||
if (selectedTaskId) {
|
||||
return realTasks.find((task) => task.id === selectedTaskId) ?? realTasks[0] ?? null;
|
||||
}
|
||||
return realTasks[0] ?? null;
|
||||
}, [selectedOpenPullRequest, selectedTaskId, tasks]);
|
||||
|
||||
const materializeOpenPullRequest = useCallback(
|
||||
async (pullRequest: WorkbenchOpenPrSummary) => {
|
||||
if (resolvingOpenPullRequestsRef.current.has(pullRequest.prId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
resolvingOpenPullRequestsRef.current.add(pullRequest.prId);
|
||||
setMaterializingOpenPrId(pullRequest.prId);
|
||||
|
||||
try {
|
||||
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
||||
repoId: pullRequest.repoId,
|
||||
task: `Continue work on GitHub PR #${pullRequest.number}: ${pullRequest.title}`,
|
||||
model: "gpt-5.3-codex",
|
||||
title: pullRequest.title,
|
||||
onBranch: pullRequest.headRefName,
|
||||
});
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
taskId,
|
||||
},
|
||||
search: { sessionId: tabId ?? undefined },
|
||||
replace: true,
|
||||
});
|
||||
} catch (error) {
|
||||
setMaterializingOpenPrId((current) => (current === pullRequest.prId ? null : current));
|
||||
resolvingOpenPullRequestsRef.current.delete(pullRequest.prId);
|
||||
logger.error(
|
||||
{
|
||||
prId: pullRequest.prId,
|
||||
repoId: pullRequest.repoId,
|
||||
branchName: pullRequest.headRefName,
|
||||
...createErrorContext(error),
|
||||
},
|
||||
"failed_to_materialize_open_pull_request_task",
|
||||
);
|
||||
}
|
||||
},
|
||||
[navigate, taskWorkbenchClient, workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedOpenPullRequest) {
|
||||
if (materializingOpenPrId) {
|
||||
resolvingOpenPullRequestsRef.current.delete(materializingOpenPrId);
|
||||
}
|
||||
setMaterializingOpenPrId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void materializeOpenPullRequest(selectedOpenPullRequest);
|
||||
}, [materializeOpenPullRequest, materializingOpenPrId, selectedOpenPullRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedOpenPullRequest || materializingOpenPrId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackTaskId = tasks[0]?.id;
|
||||
if (!fallbackTaskId) {
|
||||
return;
|
||||
|
|
@ -1291,11 +1562,12 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
search: { sessionId: fallbackTask?.tabs[0]?.id ?? undefined },
|
||||
replace: true,
|
||||
});
|
||||
}, [activeTask, tasks, navigate, workspaceId]);
|
||||
}, [activeTask, materializingOpenPrId, navigate, selectedOpenPullRequest, tasks, workspaceId]);
|
||||
|
||||
const openDiffs = activeTask ? sanitizeOpenDiffs(activeTask, openDiffsByTask[activeTask.id]) : [];
|
||||
const lastAgentTabId = activeTask ? sanitizeLastAgentTabId(activeTask, lastAgentTabIdByTask[activeTask.id]) : null;
|
||||
const activeTabId = activeTask ? sanitizeActiveTabId(activeTask, activeTabIdByTask[activeTask.id], openDiffs, lastAgentTabId) : null;
|
||||
const selectedSessionHydrating = Boolean(selectedSessionId && activeTabId === selectedSessionId && sessionState.status === "loading" && !sessionState.data);
|
||||
|
||||
const syncRouteSession = useCallback(
|
||||
(taskId: string, sessionId: string | null, replace = false) => {
|
||||
|
|
@ -1395,7 +1667,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
|
||||
|
||||
const createTask = useCallback(
|
||||
(overrideRepoId?: string) => {
|
||||
(overrideRepoId?: string, options?: { title?: string; task?: string; branch?: string; onBranch?: string }) => {
|
||||
void (async () => {
|
||||
const repoId = overrideRepoId || selectedNewTaskRepoId;
|
||||
if (!repoId) {
|
||||
|
|
@ -1404,9 +1676,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
|
||||
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
||||
repoId,
|
||||
task: "New task",
|
||||
task: options?.task ?? "New task",
|
||||
model: "gpt-5.3-codex",
|
||||
title: "New task",
|
||||
title: options?.title ?? "New task",
|
||||
...(options?.branch ? { branch: options.branch } : {}),
|
||||
...(options?.onBranch ? { onBranch: options.onBranch } : {}),
|
||||
});
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
|
|
@ -1418,7 +1692,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
});
|
||||
})();
|
||||
},
|
||||
[navigate, selectedNewTaskRepoId, workspaceId],
|
||||
[navigate, selectedNewTaskRepoId, taskWorkbenchClient, workspaceId],
|
||||
);
|
||||
|
||||
const openDiffTab = useCallback(
|
||||
|
|
@ -1447,6 +1721,14 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
|
||||
const selectTask = useCallback(
|
||||
(id: string) => {
|
||||
if (isOpenPrTaskId(id)) {
|
||||
const pullRequest = openPullRequestsByTaskId.get(id);
|
||||
if (!pullRequest) {
|
||||
return;
|
||||
}
|
||||
void materializeOpenPullRequest(pullRequest);
|
||||
return;
|
||||
}
|
||||
const task = tasks.find((candidate) => candidate.id === id) ?? null;
|
||||
void navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
|
|
@ -1457,7 +1739,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
search: { sessionId: task?.tabs[0]?.id ?? undefined },
|
||||
});
|
||||
},
|
||||
[tasks, navigate, workspaceId],
|
||||
[materializeOpenPullRequest, navigate, openPullRequestsByTaskId, tasks, workspaceId],
|
||||
);
|
||||
|
||||
const markTaskUnread = useCallback((id: string) => {
|
||||
|
|
@ -1616,6 +1898,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
};
|
||||
|
||||
if (!activeTask) {
|
||||
const isMaterializingSelectedOpenPr = Boolean(selectedOpenPullRequest) || materializingOpenPrId != null;
|
||||
return (
|
||||
<>
|
||||
{dragRegion}
|
||||
|
|
@ -1636,7 +1919,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
projects={projects}
|
||||
newTaskRepos={workspaceRepos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId=""
|
||||
activeId={selectedTaskId ?? ""}
|
||||
onSelect={selectTask}
|
||||
onCreate={createTask}
|
||||
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
|
||||
|
|
@ -1646,6 +1929,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
onReorderProjects={reorderProjects}
|
||||
taskOrderByProject={taskOrderByProject}
|
||||
onReorderTasks={reorderTasks}
|
||||
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
||||
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
||||
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
||||
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
|
||||
onToggleSidebar={() => setLeftSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1712,6 +1999,14 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
{activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.</>}
|
||||
</p>
|
||||
</>
|
||||
) : isMaterializingSelectedOpenPr && selectedOpenPullRequest ? (
|
||||
<>
|
||||
<SpinnerDot />
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Creating task from pull request</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>
|
||||
Preparing a task for <strong>{selectedOpenPullRequest.title}</strong> on <strong>{selectedOpenPullRequest.headRefName}</strong>.
|
||||
</p>
|
||||
</>
|
||||
) : activeOrg?.github.syncStatus === "error" ? (
|
||||
<>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600, color: t.statusError }}>GitHub sync failed</h2>
|
||||
|
|
@ -1766,40 +2061,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
{activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
left: "8px",
|
||||
zIndex: 99998,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 12px",
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.statusError}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
fontSize: "11px",
|
||||
color: t.textPrimary,
|
||||
maxWidth: "360px",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: t.statusError,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
|
||||
{showDevPanel && (
|
||||
<DevPanel
|
||||
workspaceId={workspaceId}
|
||||
|
|
@ -1832,7 +2094,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
projects={projects}
|
||||
newTaskRepos={workspaceRepos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId={activeTask.id}
|
||||
activeId={selectedTaskId ?? activeTask.id}
|
||||
onSelect={selectTask}
|
||||
onCreate={createTask}
|
||||
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
|
||||
|
|
@ -1842,6 +2104,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
onReorderProjects={reorderProjects}
|
||||
taskOrderByProject={taskOrderByProject}
|
||||
onReorderTasks={reorderTasks}
|
||||
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
||||
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
||||
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
||||
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
|
||||
onToggleSidebar={() => setLeftSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1880,7 +2146,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
projects={projects}
|
||||
newTaskRepos={workspaceRepos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId={activeTask.id}
|
||||
activeId={selectedTaskId ?? activeTask.id}
|
||||
onSelect={(id) => {
|
||||
selectTask(id);
|
||||
setLeftSidebarPeeking(false);
|
||||
|
|
@ -1893,6 +2159,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
onReorderProjects={reorderProjects}
|
||||
taskOrderByProject={taskOrderByProject}
|
||||
onReorderTasks={reorderTasks}
|
||||
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
||||
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
||||
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
||||
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
|
||||
onToggleSidebar={() => {
|
||||
setLeftSidebarPeeking(false);
|
||||
setLeftSidebarOpen(true);
|
||||
|
|
@ -1930,6 +2200,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
onSidebarPeekEnd={endPeek}
|
||||
rightSidebarCollapsed={!rightSidebarOpen}
|
||||
onToggleRightSidebar={() => setRightSidebarOpen(true)}
|
||||
selectedSessionHydrating={selectedSessionHydrating}
|
||||
onNavigateToUsage={navigateToUsage}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1959,40 +2230,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
left: "8px",
|
||||
zIndex: 99998,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 12px",
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.statusError}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
fontSize: "11px",
|
||||
color: t.textPrimary,
|
||||
maxWidth: "360px",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: t.statusError,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
|
||||
{showDevPanel && (
|
||||
<DevPanel
|
||||
workspaceId={workspaceId}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }:
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
padding: "6px 8px",
|
||||
|
|
@ -110,7 +110,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }:
|
|||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
transition: "background 160ms ease, color 160ms ease",
|
||||
transition: "background-color 160ms ease, color 160ms ease",
|
||||
":hover": {
|
||||
backgroundColor: t.interactiveHover,
|
||||
color: t.textPrimary,
|
||||
|
|
|
|||
|
|
@ -146,7 +146,6 @@ export const ModelPicker = memo(function ModelPicker({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
display: "flex",
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@ const FileTree = memo(function FileTree({
|
|||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "3px 10px",
|
||||
paddingTop: "3px",
|
||||
paddingRight: "10px",
|
||||
paddingBottom: "3px",
|
||||
paddingLeft: `${10 + depth * 16}px`,
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
|
|
@ -175,7 +177,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
|
|
@ -202,7 +204,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
|
|
@ -230,7 +232,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
|
|
@ -312,17 +314,16 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
marginTop: "6px",
|
||||
marginRight: "0",
|
||||
marginBottom: "6px",
|
||||
marginLeft: "6px",
|
||||
boxSizing: "border-box",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "4px 12px",
|
||||
marginTop: "6px",
|
||||
marginBottom: "6px",
|
||||
marginLeft: "6px",
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
|
|
@ -363,15 +364,15 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
marginTop: "6px",
|
||||
marginRight: "0",
|
||||
marginBottom: "6px",
|
||||
marginLeft: "0",
|
||||
boxSizing: "border-box",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 12px",
|
||||
marginTop: "6px",
|
||||
marginBottom: "6px",
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
GitPullRequestDraft,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
MoreHorizontal,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
Settings,
|
||||
|
|
@ -52,6 +53,10 @@ function projectIconColor(label: string): string {
|
|||
return PROJECT_COLORS[Math.abs(hash) % PROJECT_COLORS.length]!;
|
||||
}
|
||||
|
||||
function isPullRequestSidebarItem(task: Task): boolean {
|
||||
return task.id.startsWith("pr:");
|
||||
}
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
projects,
|
||||
newTaskRepos,
|
||||
|
|
@ -66,6 +71,10 @@ export const Sidebar = memo(function Sidebar({
|
|||
onReorderProjects,
|
||||
taskOrderByProject,
|
||||
onReorderTasks,
|
||||
onReloadOrganization,
|
||||
onReloadPullRequests,
|
||||
onReloadRepository,
|
||||
onReloadPullRequest,
|
||||
onToggleSidebar,
|
||||
}: {
|
||||
projects: ProjectSection[];
|
||||
|
|
@ -81,6 +90,10 @@ export const Sidebar = memo(function Sidebar({
|
|||
onReorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
taskOrderByProject: Record<string, string[]>;
|
||||
onReorderTasks: (projectId: string, fromIndex: number, toIndex: number) => void;
|
||||
onReloadOrganization: () => void;
|
||||
onReloadPullRequests: () => void;
|
||||
onReloadRepository: (repoId: string) => void;
|
||||
onReloadPullRequest: (repoId: string, prNumber: number) => void;
|
||||
onToggleSidebar?: () => void;
|
||||
}) {
|
||||
const [css] = useStyletron();
|
||||
|
|
@ -88,6 +101,8 @@ export const Sidebar = memo(function Sidebar({
|
|||
const contextMenu = useContextMenu();
|
||||
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({});
|
||||
const [hoveredProjectId, setHoveredProjectId] = useState<string | null>(null);
|
||||
const [headerMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const headerMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Mouse-based drag and drop state
|
||||
type DragState =
|
||||
|
|
@ -149,6 +164,20 @@ export const Sidebar = memo(function Sidebar({
|
|||
};
|
||||
}, [drag, onReorderProjects, onReorderTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!headerMenuOpen) {
|
||||
return;
|
||||
}
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
if (headerMenuRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setHeaderMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||
}, [headerMenuOpen]);
|
||||
|
||||
const [createSelectOpen, setCreateSelectOpen] = useState(false);
|
||||
const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]);
|
||||
|
||||
|
|
@ -326,47 +355,111 @@ export const Sidebar = memo(function Sidebar({
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-disabled={newTaskRepos.length === 0}
|
||||
onClick={() => {
|
||||
if (newTaskRepos.length === 0) return;
|
||||
if (newTaskRepos.length === 1) {
|
||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||
onCreate(newTaskRepos[0]!.id);
|
||||
} else {
|
||||
setCreateSelectOpen(true);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (newTaskRepos.length === 0) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "6px", position: "relative" })} ref={headerMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHeaderMenuOpen((value) => !value)}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
backgroundColor: t.interactiveHover,
|
||||
color: t.textPrimary,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "background 200ms ease",
|
||||
flexShrink: 0,
|
||||
":hover": { backgroundColor: t.borderMedium },
|
||||
})}
|
||||
title="GitHub actions"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{headerMenuOpen ? (
|
||||
<div
|
||||
className={css({
|
||||
position: "absolute",
|
||||
top: "32px",
|
||||
right: 0,
|
||||
minWidth: "180px",
|
||||
padding: "6px",
|
||||
borderRadius: "10px",
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
boxShadow: `${t.shadow}, 0 0 0 1px ${t.interactiveSubtle}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
zIndex: 20,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHeaderMenuOpen(false);
|
||||
onReloadOrganization();
|
||||
}}
|
||||
className={css(menuButtonStyle(false, t))}
|
||||
>
|
||||
Reload organization
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHeaderMenuOpen(false);
|
||||
onReloadPullRequests();
|
||||
}}
|
||||
className={css(menuButtonStyle(false, t))}
|
||||
>
|
||||
Reload all PRs
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-disabled={newTaskRepos.length === 0}
|
||||
onClick={() => {
|
||||
if (newTaskRepos.length === 0) return;
|
||||
if (newTaskRepos.length === 1) {
|
||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||
onCreate(newTaskRepos[0]!.id);
|
||||
} else {
|
||||
setCreateSelectOpen(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: newTaskRepos.length > 0 ? t.borderMedium : t.interactiveHover,
|
||||
color: t.textPrimary,
|
||||
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "background 200ms ease",
|
||||
flexShrink: 0,
|
||||
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
|
||||
":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined,
|
||||
})}
|
||||
>
|
||||
<Plus size={14} style={{ display: "block" }} />
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (newTaskRepos.length === 0) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
if (newTaskRepos.length === 1) {
|
||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||
onCreate(newTaskRepos[0]!.id);
|
||||
} else {
|
||||
setCreateSelectOpen(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: newTaskRepos.length > 0 ? t.borderMedium : t.interactiveHover,
|
||||
color: t.textPrimary,
|
||||
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "background 200ms ease",
|
||||
flexShrink: 0,
|
||||
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
|
||||
":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined,
|
||||
})}
|
||||
>
|
||||
<Plus size={14} style={{ display: "block" }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PanelHeaderBar>
|
||||
|
|
@ -431,6 +524,12 @@ export const Sidebar = memo(function Sidebar({
|
|||
}));
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) =>
|
||||
contextMenu.open(event, [
|
||||
{ label: "Reload repository", onClick: () => onReloadRepository(project.id) },
|
||||
{ label: "New task", onClick: () => onCreate(project.id) },
|
||||
])
|
||||
}
|
||||
data-project-header
|
||||
className={css({
|
||||
display: "flex",
|
||||
|
|
@ -499,13 +598,13 @@ export const Sidebar = memo(function Sidebar({
|
|||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
background: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: "pointer",
|
||||
color: t.textTertiary,
|
||||
opacity: hoveredProjectId === project.id ? 1 : 0,
|
||||
transition: "opacity 150ms ease, background 200ms ease, color 200ms ease",
|
||||
transition: "opacity 150ms ease, background-color 200ms ease, color 200ms ease",
|
||||
pointerEvents: hoveredProjectId === project.id ? "auto" : "none",
|
||||
":hover": { backgroundColor: t.interactiveHover, color: t.textSecondary },
|
||||
})}
|
||||
|
|
@ -519,12 +618,14 @@ export const Sidebar = memo(function Sidebar({
|
|||
{!isCollapsed &&
|
||||
orderedTasks.map((task, taskIndex) => {
|
||||
const isActive = task.id === activeId;
|
||||
const isPullRequestItem = isPullRequestSidebarItem(task);
|
||||
const isDim = task.status === "archived";
|
||||
const isRunning = task.tabs.some((tab) => tab.status === "running");
|
||||
const isProvisioning =
|
||||
String(task.status).startsWith("init_") ||
|
||||
task.status === "new" ||
|
||||
task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create");
|
||||
!isPullRequestItem &&
|
||||
(String(task.status).startsWith("init_") ||
|
||||
task.status === "new" ||
|
||||
task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create"));
|
||||
const hasUnread = task.tabs.some((tab) => tab.unread);
|
||||
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
|
||||
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
|
||||
|
|
@ -554,13 +655,20 @@ export const Sidebar = memo(function Sidebar({
|
|||
onSelect(task.id);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) =>
|
||||
onContextMenu={(event) => {
|
||||
if (isPullRequestItem && task.pullRequest) {
|
||||
contextMenu.open(event, [
|
||||
{ label: "Reload pull request", onClick: () => onReloadPullRequest(task.repoId, task.pullRequest!.number) },
|
||||
{ label: "Create task", onClick: () => onSelect(task.id) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
contextMenu.open(event, [
|
||||
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
|
||||
{ label: "Rename branch", onClick: () => onRenameBranch(task.id) },
|
||||
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
|
||||
])
|
||||
}
|
||||
]);
|
||||
}}
|
||||
className={css({
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
|
|
@ -596,21 +704,32 @@ export const Sidebar = memo(function Sidebar({
|
|||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
|
||||
{isPullRequestItem ? (
|
||||
<GitPullRequestDraft size={13} color={isDraft ? t.accent : t.textSecondary} />
|
||||
) : (
|
||||
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
|
||||
)}
|
||||
</div>
|
||||
<div className={css({ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: "1px" })}>
|
||||
<LabelSmall
|
||||
$style={{
|
||||
fontWeight: hasUnread ? 600 : 400,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
color={hasUnread ? t.textPrimary : t.textSecondary}
|
||||
>
|
||||
{task.title}
|
||||
</LabelSmall>
|
||||
{isPullRequestItem && task.statusMessage ? (
|
||||
<LabelXSmall color={t.textTertiary} $style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{task.statusMessage}
|
||||
</LabelXSmall>
|
||||
) : null}
|
||||
</div>
|
||||
<LabelSmall
|
||||
$style={{
|
||||
fontWeight: hasUnread ? 600 : 400,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
color={hasUnread ? t.textPrimary : t.textSecondary}
|
||||
>
|
||||
{task.title}
|
||||
</LabelSmall>
|
||||
{task.pullRequest != null ? (
|
||||
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
||||
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>
|
||||
|
|
|
|||
|
|
@ -543,7 +543,10 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
gap: "6px",
|
||||
minHeight: "39px",
|
||||
maxHeight: "39px",
|
||||
padding: "0 14px",
|
||||
paddingTop: "0",
|
||||
paddingRight: "14px",
|
||||
paddingBottom: "0",
|
||||
paddingLeft: "14px",
|
||||
borderTop: `1px solid ${t.borderDefault}`,
|
||||
backgroundColor: t.surfacePrimary,
|
||||
flexShrink: 0,
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@ export const TranscriptHeader = memo(function TranscriptHeader({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
margin: "0",
|
||||
outline: "none",
|
||||
padding: "2px 8px",
|
||||
|
|
|
|||
|
|
@ -299,7 +299,10 @@ export const PanelHeaderBar = styled("div", ({ $theme }) => {
|
|||
alignItems: "center",
|
||||
minHeight: HEADER_HEIGHT,
|
||||
maxHeight: HEADER_HEIGHT,
|
||||
padding: "0 14px",
|
||||
paddingTop: "0",
|
||||
paddingRight: "14px",
|
||||
paddingBottom: "0",
|
||||
paddingLeft: "14px",
|
||||
borderBottom: `1px solid ${t.borderDefault}`,
|
||||
backgroundColor: t.surfaceTertiary,
|
||||
gap: "8px",
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ export interface FoundryGithubState {
|
|||
importedRepoCount: number;
|
||||
lastSyncLabel: string;
|
||||
lastSyncAt: number | null;
|
||||
lastWebhookAt: number | null;
|
||||
lastWebhookEvent: string;
|
||||
}
|
||||
|
||||
export interface FoundryOrganizationSettings {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { FoundryAppSnapshot } from "./app-shell.js";
|
||||
import type { WorkbenchRepoSummary, WorkbenchSessionDetail, WorkbenchTaskDetail, WorkbenchTaskSummary } from "./workbench.js";
|
||||
import type { WorkbenchOpenPrSummary, WorkbenchRepoSummary, WorkbenchSessionDetail, WorkbenchTaskDetail, WorkbenchTaskSummary } from "./workbench.js";
|
||||
|
||||
export interface SandboxProcessSnapshot {
|
||||
id: string;
|
||||
|
|
@ -21,7 +21,9 @@ export type WorkspaceEvent =
|
|||
| { type: "taskRemoved"; taskId: string }
|
||||
| { type: "repoAdded"; repo: WorkbenchRepoSummary }
|
||||
| { type: "repoUpdated"; repo: WorkbenchRepoSummary }
|
||||
| { type: "repoRemoved"; repoId: string };
|
||||
| { type: "repoRemoved"; repoId: string }
|
||||
| { type: "pullRequestUpdated"; pullRequest: WorkbenchOpenPrSummary }
|
||||
| { type: "pullRequestRemoved"; prId: string };
|
||||
|
||||
/** Task-level events broadcast by the task actor. */
|
||||
export type TaskEvent = { type: "taskDetailUpdated"; detail: WorkbenchTaskDetail };
|
||||
|
|
|
|||
|
|
@ -105,6 +105,21 @@ export interface WorkbenchPullRequestSummary {
|
|||
status: "draft" | "ready";
|
||||
}
|
||||
|
||||
export interface WorkbenchOpenPrSummary {
|
||||
prId: string;
|
||||
repoId: string;
|
||||
repoFullName: string;
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
authorLogin: string | null;
|
||||
isDraft: boolean;
|
||||
updatedAtMs: number;
|
||||
}
|
||||
|
||||
export interface WorkbenchSandboxSummary {
|
||||
providerId: ProviderId;
|
||||
sandboxId: string;
|
||||
|
|
@ -161,6 +176,7 @@ export interface WorkspaceSummarySnapshot {
|
|||
workspaceId: string;
|
||||
repos: WorkbenchRepoSummary[];
|
||||
taskSummaries: WorkbenchTaskSummary[];
|
||||
openPullRequests: WorkbenchOpenPrSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -229,6 +245,7 @@ export interface TaskWorkbenchCreateTaskInput {
|
|||
task: string;
|
||||
title?: string;
|
||||
branch?: string;
|
||||
onBranch?: string;
|
||||
model?: WorkbenchModelId;
|
||||
}
|
||||
|
||||
|
|
|
|||
169
foundry/research/specs/github-data-actor.md
Normal file
169
foundry/research/specs/github-data-actor.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Spec: GitHub Data Actor & Webhook-Driven State
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the per-repo polling PR sync actor (`ProjectPrSyncActor`) and per-repo PR cache (`prCache` table) with a single organization-scoped `github-state` actor that owns all GitHub data (repos, PRs, members). All GitHub state updates flow exclusively through webhooks, with a one-shot full sync on initial connection. Manual reload actions are exposed per-entity (org, repo, PR) for recovery from missed webhooks.
|
||||
|
||||
Open PRs are surfaced in the left sidebar alongside tasks via a unified workspace interest topic, with lazy task/sandbox creation when a user clicks on a PR.
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
A prior implementation of the `github-state` actor exists in git checkpoint `0aca2c7` (from PR #247 "Refactor Foundry GitHub state and sandbox runtime"). This was never merged to a branch but contains working code for:
|
||||
|
||||
- `foundry/packages/backend/src/actors/github-state/index.ts` — full actor with DB, sync workflow, webhook handler, PR CRUD
|
||||
- `foundry/packages/backend/src/actors/github-state/db/schema.ts` — `github_meta`, `github_repositories`, `github_members`, `github_pull_requests` tables
|
||||
- `foundry/packages/backend/src/actors/organization/app-shell.ts` lines 1056-1180 — webhook dispatch to `githubState.handlePullRequestWebhook()` and `githubState.fullSync()`
|
||||
|
||||
Use `git show 0aca2c7:<path>` to read the reference files. Adapt (don't copy blindly) — the current branch structure has diverged.
|
||||
|
||||
## Constraints
|
||||
|
||||
1. **No polling.** Delete `ProjectPrSyncActor` (`actors/project-pr-sync/`), all references to it in handles/keys/index, and the `prCache` table in `ProjectActor`'s DB schema. Remove `prSyncStatus`/`prSyncAt` from `getRepoOverview`.
|
||||
2. **Keep `ProjectBranchSyncActor`.** This polls the local git clone (not GitHub API) and is the sandbox git status mechanism. It stays.
|
||||
3. **Webhooks are the sole live update path.** The only GitHub API calls happen during:
|
||||
- Initial full sync on org connection/installation
|
||||
- Manual reload actions (per-entity)
|
||||
4. **GitHub does not auto-retry failed webhook deliveries** ([docs](https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries)). Manual reload is the recovery mechanism.
|
||||
5. **No `user-github-data` actor in this spec.** OAuth/auth is already handled correctly on the current branch. Only the org-scoped `github-state` actor is in scope.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Actor: `github-state` (one per organization)
|
||||
|
||||
**Key:** `["org", organizationId, "github"]`
|
||||
|
||||
**DB tables:**
|
||||
- `github_meta` — sync status, installation info, connected account
|
||||
- `github_repositories` — repos accessible via the GitHub App installation
|
||||
- `github_pull_requests` — all open PRs across all repos in the org
|
||||
- `github_members` — org members (existing from checkpoint, keep for completeness)
|
||||
|
||||
**Actions (from checkpoint, to adapt):**
|
||||
- `fullSync(input)` — one-shot fetch of repos + PRs via installation token. Enqueues as a workflow step. Used on initial connection and `installation.created`/`unsuspend` webhooks.
|
||||
- `handlePullRequestWebhook(input)` — upserts a single PR from webhook payload, notifies downstream.
|
||||
- `getSummary()` — returns sync meta + row counts.
|
||||
- `listRepositories()` — returns all known repos.
|
||||
- `listPullRequestsForRepository({ repoId })` — returns PRs for a repo.
|
||||
- `getPullRequestForBranch({ repoId, branchName })` — returns PR info for a branch.
|
||||
- `createPullRequest({ repoId, repoPath, branchName, title, body })` — creates PR via GitHub API, stores locally.
|
||||
- `clearState(input)` — wipes all data (on `installation.deleted`, `suspend`).
|
||||
|
||||
**New actions (not in checkpoint):**
|
||||
- `reloadOrganization()` — re-fetches repos + members from GitHub API (not PRs). Updates `github_repositories` and `github_members`. Notifies downstream.
|
||||
- `reloadRepository({ repoId })` — re-fetches metadata for a single repo from GitHub API. Updates the `github_repositories` row. Does NOT re-fetch PRs.
|
||||
- `reloadPullRequest({ repoId, prNumber })` — re-fetches a single PR from GitHub API by number. Updates the `github_pull_requests` row. Notifies downstream.
|
||||
|
||||
### Webhook Dispatch (in app-shell)
|
||||
|
||||
Replace the current TODO at `app-shell.ts:1521` with dispatch logic adapted from checkpoint `0aca2c7:foundry/packages/backend/src/actors/organization/app-shell.ts` lines 1056-1180:
|
||||
|
||||
| Webhook event | Action |
|
||||
|---|---|
|
||||
| `installation.created` | `githubState.fullSync({ force: true })` |
|
||||
| `installation.deleted` | `githubState.clearState(...)` |
|
||||
| `installation.suspend` | `githubState.clearState(...)` |
|
||||
| `installation.unsuspend` | `githubState.fullSync({ force: true })` |
|
||||
| `installation_repositories` | `githubState.fullSync({ force: true })` |
|
||||
| `pull_request` (any action) | `githubState.handlePullRequestWebhook(...)` |
|
||||
| `push`, `create`, `delete`, `check_run`, `check_suite`, `status`, `pull_request_review`, `pull_request_review_comment` | Log for now, extend later |
|
||||
|
||||
### Downstream Notifications
|
||||
|
||||
When `github-state` receives a PR update (webhook or manual reload), it should:
|
||||
|
||||
1. Update its own `github_pull_requests` table
|
||||
2. Call `notifyOrganizationUpdated()` → which broadcasts `workspaceUpdated` to connected clients
|
||||
3. If the PR branch matches an existing task's branch, update that task's `pullRequest` summary in the workspace actor
|
||||
|
||||
### Workspace Summary Changes
|
||||
|
||||
Extend `WorkspaceSummarySnapshot` to include open PRs:
|
||||
|
||||
```typescript
|
||||
export interface WorkspaceSummarySnapshot {
|
||||
workspaceId: string;
|
||||
repos: WorkbenchRepoSummary[];
|
||||
taskSummaries: WorkbenchTaskSummary[];
|
||||
openPullRequests: WorkbenchOpenPrSummary[]; // NEW
|
||||
}
|
||||
|
||||
export interface WorkbenchOpenPrSummary {
|
||||
prId: string; // "repoId#number"
|
||||
repoId: string;
|
||||
repoFullName: string;
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
authorLogin: string | null;
|
||||
isDraft: boolean;
|
||||
updatedAtMs: number;
|
||||
}
|
||||
```
|
||||
|
||||
The workspace actor fetches open PRs from the `github-state` actor when building the summary snapshot. PRs that already have an associated task (matched by branch name) should be excluded from `openPullRequests` (they already appear in `taskSummaries` with their `pullRequest` field populated).
|
||||
|
||||
### Interest Manager
|
||||
|
||||
The `workspace` interest topic already returns `WorkspaceSummarySnapshot`. Adding `openPullRequests` to that type means the sidebar automatically gets PR data without a new topic.
|
||||
|
||||
`workspaceUpdated` events should include a new variant for PR changes:
|
||||
```typescript
|
||||
{ type: "pullRequestUpdated", pullRequest: WorkbenchOpenPrSummary }
|
||||
{ type: "pullRequestRemoved", prId: string }
|
||||
```
|
||||
|
||||
### Sidebar Changes
|
||||
|
||||
The left sidebar currently renders `projects: ProjectSection[]` where each project has `tasks: Task[]`. Extend this to include open PRs as lightweight entries within each project section:
|
||||
|
||||
- Open PRs appear in the same list as tasks, sorted by `updatedAtMs`
|
||||
- PRs should be visually distinct: show PR icon instead of task indicator, display `#number` and author
|
||||
- Clicking a PR creates a task lazily (creates the task + sandbox on demand), then navigates to it
|
||||
- PRs that already have a task are filtered out (they show as the task instead)
|
||||
|
||||
This is similar to what `buildPrTasks()` does in the mock data (`workbench-model.ts:1154-1182`), but driven by real data from the `github-state` actor.
|
||||
|
||||
### Frontend: Manual Reload
|
||||
|
||||
Add a "three dots" menu button in the top-right of the sidebar header. Dropdown options:
|
||||
|
||||
- **Reload organization** — calls `githubState.reloadOrganization()` via backend API
|
||||
- **Reload all PRs** — calls `githubState.fullSync({ force: true })` (convenience shortcut)
|
||||
|
||||
For per-repo and per-PR reload, add context menu options:
|
||||
- Right-click a project header → "Reload repository"
|
||||
- Right-click a PR entry → "Reload pull request"
|
||||
|
||||
These call the corresponding `reloadRepository`/`reloadPullRequest` actions on the `github-state` actor.
|
||||
|
||||
## Deletions
|
||||
|
||||
Files/code to remove:
|
||||
|
||||
1. `foundry/packages/backend/src/actors/project-pr-sync/` — entire directory
|
||||
2. `foundry/packages/backend/src/actors/project/db/schema.ts` — `prCache` table
|
||||
3. `foundry/packages/backend/src/actors/project/actions.ts` — `applyPrSyncResultMutation`, `getPullRequestForBranch` (moves to github-state), `prSyncStatus`/`prSyncAt` from `getRepoOverview`
|
||||
4. `foundry/packages/backend/src/actors/handles.ts` — `getOrCreateProjectPrSync`, `selfProjectPrSync`
|
||||
5. `foundry/packages/backend/src/actors/keys.ts` — any PR sync key helper
|
||||
6. `foundry/packages/backend/src/actors/index.ts` — `projectPrSync` import and registration
|
||||
7. All call sites in `ProjectActor` that spawn or call the PR sync actor (`initProject`, `refreshProject`)
|
||||
|
||||
## Migration Path
|
||||
|
||||
The `prCache` table in `ProjectActor`'s DB can simply be dropped — no data migration needed since the `github-state` actor will re-fetch everything on its first `fullSync`. Existing task `pullRequest` fields are populated from the github-state actor going forward.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Create `github-state` actor (adapt from checkpoint `0aca2c7`)
|
||||
2. Wire up actor in registry, handles, keys
|
||||
3. Implement webhook dispatch in app-shell (replace TODO)
|
||||
4. Delete `ProjectPrSyncActor` and `prCache` from project actor
|
||||
5. Add manual reload actions to github-state
|
||||
6. Extend `WorkspaceSummarySnapshot` with `openPullRequests`
|
||||
7. Wire through interest manager + workspace events
|
||||
8. Update sidebar to render open PRs
|
||||
9. Add three-dots menu with reload options
|
||||
10. Update task creation flow for lazy PR→task conversion
|
||||
Loading…
Add table
Add a link
Reference in a new issue