This commit is contained in:
Nathan Flurry 2026-03-14 14:38:29 -07:00 committed by GitHub
parent 70d31f819c
commit 5ea9ec5e2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2605 additions and 669 deletions

View 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 });

View file

@ -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,
};

View 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(),
});

View 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,
});
}
}
},
},
});

View file

@ -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);
}

View file

@ -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";

View file

@ -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 {

View file

@ -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),
});
}
},
});
}),
});

View file

@ -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, {

View file

@ -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,

View file

@ -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"),

View file

@ -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> {

View file

@ -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);

View file

@ -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) => {

View file

@ -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);

View file

@ -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 };
}

View file

@ -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,

View file

@ -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",

View file

@ -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,

View file

@ -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"),

View file

@ -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[] = [];

View file

@ -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;
}

View file

@ -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"),
];

View file

@ -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);
});
});

View file

@ -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) {

View file

@ -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>,

View file

@ -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);

View file

@ -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 };
},

View file

@ -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> {

View file

@ -64,6 +64,7 @@ function workspaceSnapshot(): WorkspaceSummarySnapshot {
sessionsSummary: [],
},
],
openPullRequests: [],
};
}

View file

@ -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>

View file

@ -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}

View file

@ -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,

View file

@ -146,7 +146,6 @@ export const ModelPicker = memo(function ModelPicker({
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
display: "flex",

View file

@ -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",

View file

@ -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 }}>

View file

@ -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,

View file

@ -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",

View file

@ -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",

View file

@ -50,6 +50,8 @@ export interface FoundryGithubState {
importedRepoCount: number;
lastSyncLabel: string;
lastSyncAt: number | null;
lastWebhookAt: number | null;
lastWebhookEvent: string;
}
export interface FoundryOrganizationSettings {

View file

@ -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 };

View file

@ -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;
}