mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 06:04:43 +00:00
wip (#253)
This commit is contained in:
parent
70d31f819c
commit
5ea9ec5e2f
47 changed files with 2605 additions and 669 deletions
5
foundry/packages/backend/src/actors/github-data/db/db.ts
Normal file
5
foundry/packages/backend/src/actors/github-data/db/db.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { db } from "rivetkit/db/drizzle";
|
||||
import * as schema from "./schema.js";
|
||||
import migrations from "./migrations.js";
|
||||
|
||||
export const githubDataDb = db({ schema, migrations });
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
const journal = {
|
||||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
when: 1773446400000,
|
||||
tag: "0000_github_data",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export default {
|
||||
journal,
|
||||
migrations: {
|
||||
m0000: `CREATE TABLE \`github_meta\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`connected_account\` text NOT NULL,
|
||||
\`installation_status\` text NOT NULL,
|
||||
\`sync_status\` text NOT NULL,
|
||||
\`installation_id\` integer,
|
||||
\`last_sync_label\` text NOT NULL,
|
||||
\`last_sync_at\` integer,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`github_repositories\` (
|
||||
\`repo_id\` text PRIMARY KEY NOT NULL,
|
||||
\`full_name\` text NOT NULL,
|
||||
\`clone_url\` text NOT NULL,
|
||||
\`private\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`github_members\` (
|
||||
\`member_id\` text PRIMARY KEY NOT NULL,
|
||||
\`login\` text NOT NULL,
|
||||
\`display_name\` text NOT NULL,
|
||||
\`email\` text,
|
||||
\`role\` text,
|
||||
\`state\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`github_pull_requests\` (
|
||||
\`pr_id\` text PRIMARY KEY NOT NULL,
|
||||
\`repo_id\` text NOT NULL,
|
||||
\`repo_full_name\` text NOT NULL,
|
||||
\`number\` integer NOT NULL,
|
||||
\`title\` text NOT NULL,
|
||||
\`body\` text,
|
||||
\`state\` text NOT NULL,
|
||||
\`url\` text NOT NULL,
|
||||
\`head_ref_name\` text NOT NULL,
|
||||
\`base_ref_name\` text NOT NULL,
|
||||
\`author_login\` text,
|
||||
\`is_draft\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
46
foundry/packages/backend/src/actors/github-data/db/schema.ts
Normal file
46
foundry/packages/backend/src/actors/github-data/db/schema.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
|
||||
export const githubMeta = sqliteTable("github_meta", {
|
||||
id: integer("id").primaryKey(),
|
||||
connectedAccount: text("connected_account").notNull(),
|
||||
installationStatus: text("installation_status").notNull(),
|
||||
syncStatus: text("sync_status").notNull(),
|
||||
installationId: integer("installation_id"),
|
||||
lastSyncLabel: text("last_sync_label").notNull(),
|
||||
lastSyncAt: integer("last_sync_at"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const githubRepositories = sqliteTable("github_repositories", {
|
||||
repoId: text("repo_id").notNull().primaryKey(),
|
||||
fullName: text("full_name").notNull(),
|
||||
cloneUrl: text("clone_url").notNull(),
|
||||
private: integer("private").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const githubMembers = sqliteTable("github_members", {
|
||||
memberId: text("member_id").notNull().primaryKey(),
|
||||
login: text("login").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
email: text("email"),
|
||||
role: text("role"),
|
||||
state: text("state").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const githubPullRequests = sqliteTable("github_pull_requests", {
|
||||
prId: text("pr_id").notNull().primaryKey(),
|
||||
repoId: text("repo_id").notNull(),
|
||||
repoFullName: text("repo_full_name").notNull(),
|
||||
number: integer("number").notNull(),
|
||||
title: text("title").notNull(),
|
||||
body: text("body"),
|
||||
state: text("state").notNull(),
|
||||
url: text("url").notNull(),
|
||||
headRefName: text("head_ref_name").notNull(),
|
||||
baseRefName: text("base_ref_name").notNull(),
|
||||
authorLogin: text("author_login"),
|
||||
isDraft: integer("is_draft").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
775
foundry/packages/backend/src/actors/github-data/index.ts
Normal file
775
foundry/packages/backend/src/actors/github-data/index.ts
Normal file
|
|
@ -0,0 +1,775 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { actor } from "rivetkit";
|
||||
import type { FoundryOrganization } from "@sandbox-agent/foundry-shared";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getOrCreateWorkspace, getTask } from "../handles.js";
|
||||
import { repoIdFromRemote } from "../../services/repo.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { githubDataDb } from "./db/db.js";
|
||||
import { githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js";
|
||||
|
||||
const META_ROW_ID = 1;
|
||||
|
||||
interface GithubDataInput {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface GithubMemberRecord {
|
||||
id: string;
|
||||
login: string;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
role?: string | null;
|
||||
state?: string | null;
|
||||
}
|
||||
|
||||
interface GithubRepositoryRecord {
|
||||
fullName: string;
|
||||
cloneUrl: string;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
interface GithubPullRequestRecord {
|
||||
repoId: string;
|
||||
repoFullName: string;
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
authorLogin: string | null;
|
||||
isDraft: boolean;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface FullSyncInput {
|
||||
connectedAccount?: string | null;
|
||||
installationStatus?: FoundryOrganization["github"]["installationStatus"];
|
||||
installationId?: number | null;
|
||||
githubLogin?: string | null;
|
||||
kind?: FoundryOrganization["kind"] | null;
|
||||
accessToken?: string | null;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
interface ClearStateInput {
|
||||
connectedAccount: string;
|
||||
installationStatus: FoundryOrganization["github"]["installationStatus"];
|
||||
installationId: number | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PullRequestWebhookInput {
|
||||
connectedAccount: string;
|
||||
installationStatus: FoundryOrganization["github"]["installationStatus"];
|
||||
installationId: number | null;
|
||||
repository: {
|
||||
fullName: string;
|
||||
cloneUrl: string;
|
||||
private: boolean;
|
||||
};
|
||||
pullRequest: {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
authorLogin: string | null;
|
||||
isDraft: boolean;
|
||||
merged?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePrStatus(input: { state: string; isDraft?: boolean; merged?: boolean }): "OPEN" | "DRAFT" | "CLOSED" | "MERGED" {
|
||||
const state = input.state.trim().toUpperCase();
|
||||
if (input.merged || state === "MERGED") return "MERGED";
|
||||
if (state === "CLOSED") return "CLOSED";
|
||||
return input.isDraft ? "DRAFT" : "OPEN";
|
||||
}
|
||||
|
||||
function pullRequestSummaryFromRow(row: any) {
|
||||
return {
|
||||
prId: row.prId,
|
||||
repoId: row.repoId,
|
||||
repoFullName: row.repoFullName,
|
||||
number: row.number,
|
||||
title: row.title,
|
||||
state: row.state,
|
||||
url: row.url,
|
||||
headRefName: row.headRefName,
|
||||
baseRefName: row.baseRefName,
|
||||
authorLogin: row.authorLogin ?? null,
|
||||
isDraft: Boolean(row.isDraft),
|
||||
updatedAtMs: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function readMeta(c: any) {
|
||||
const row = await c.db.select().from(githubMeta).where(eq(githubMeta.id, META_ROW_ID)).get();
|
||||
return {
|
||||
connectedAccount: row?.connectedAccount ?? "",
|
||||
installationStatus: (row?.installationStatus ?? "install_required") as FoundryOrganization["github"]["installationStatus"],
|
||||
syncStatus: (row?.syncStatus ?? "pending") as FoundryOrganization["github"]["syncStatus"],
|
||||
installationId: row?.installationId ?? null,
|
||||
lastSyncLabel: row?.lastSyncLabel ?? "Waiting for first import",
|
||||
lastSyncAt: row?.lastSyncAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeMeta(c: any, patch: Partial<Awaited<ReturnType<typeof readMeta>>>) {
|
||||
const current = await readMeta(c);
|
||||
const next = {
|
||||
...current,
|
||||
...patch,
|
||||
};
|
||||
await c.db
|
||||
.insert(githubMeta)
|
||||
.values({
|
||||
id: META_ROW_ID,
|
||||
connectedAccount: next.connectedAccount,
|
||||
installationStatus: next.installationStatus,
|
||||
syncStatus: next.syncStatus,
|
||||
installationId: next.installationId,
|
||||
lastSyncLabel: next.lastSyncLabel,
|
||||
lastSyncAt: next.lastSyncAt,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: githubMeta.id,
|
||||
set: {
|
||||
connectedAccount: next.connectedAccount,
|
||||
installationStatus: next.installationStatus,
|
||||
syncStatus: next.syncStatus,
|
||||
installationId: next.installationId,
|
||||
lastSyncLabel: next.lastSyncLabel,
|
||||
lastSyncAt: next.lastSyncAt,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
.run();
|
||||
return next;
|
||||
}
|
||||
|
||||
async function getOrganizationContext(c: any, overrides?: FullSyncInput) {
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
const organization = await workspace.getOrganizationShellStateIfInitialized({});
|
||||
if (!organization) {
|
||||
throw new Error(`Workspace ${c.state.workspaceId} is not initialized`);
|
||||
}
|
||||
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
||||
return {
|
||||
kind: overrides?.kind ?? organization.snapshot.kind,
|
||||
githubLogin: overrides?.githubLogin ?? organization.githubLogin,
|
||||
connectedAccount: overrides?.connectedAccount ?? organization.snapshot.github.connectedAccount ?? organization.githubLogin,
|
||||
installationId: overrides?.installationId ?? organization.githubInstallationId ?? null,
|
||||
installationStatus:
|
||||
overrides?.installationStatus ??
|
||||
organization.snapshot.github.installationStatus ??
|
||||
(organization.snapshot.kind === "personal" ? "connected" : "reconnect_required"),
|
||||
accessToken: overrides?.accessToken ?? auth?.githubToken ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function replaceRepositories(c: any, repositories: GithubRepositoryRecord[], updatedAt: number) {
|
||||
await c.db.delete(githubRepositories).run();
|
||||
for (const repository of repositories) {
|
||||
await c.db
|
||||
.insert(githubRepositories)
|
||||
.values({
|
||||
repoId: repoIdFromRemote(repository.cloneUrl),
|
||||
fullName: repository.fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
private: repository.private ? 1 : 0,
|
||||
updatedAt,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceMembers(c: any, members: GithubMemberRecord[], updatedAt: number) {
|
||||
await c.db.delete(githubMembers).run();
|
||||
for (const member of members) {
|
||||
await c.db
|
||||
.insert(githubMembers)
|
||||
.values({
|
||||
memberId: member.id,
|
||||
login: member.login,
|
||||
displayName: member.name || member.login,
|
||||
email: member.email ?? null,
|
||||
role: member.role ?? null,
|
||||
state: member.state ?? "active",
|
||||
updatedAt,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
async function replacePullRequests(c: any, pullRequests: GithubPullRequestRecord[]) {
|
||||
await c.db.delete(githubPullRequests).run();
|
||||
for (const pullRequest of pullRequests) {
|
||||
await c.db
|
||||
.insert(githubPullRequests)
|
||||
.values({
|
||||
prId: `${pullRequest.repoId}#${pullRequest.number}`,
|
||||
repoId: pullRequest.repoId,
|
||||
repoFullName: pullRequest.repoFullName,
|
||||
number: pullRequest.number,
|
||||
title: pullRequest.title,
|
||||
body: pullRequest.body ?? null,
|
||||
state: pullRequest.state,
|
||||
url: pullRequest.url,
|
||||
headRefName: pullRequest.headRefName,
|
||||
baseRefName: pullRequest.baseRefName,
|
||||
authorLogin: pullRequest.authorLogin ?? null,
|
||||
isDraft: pullRequest.isDraft ? 1 : 0,
|
||||
updatedAt: pullRequest.updatedAt,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTaskSummaryForBranch(c: any, repoId: string, branchName: string) {
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.refreshTaskSummaryForGithubBranch({ repoId, branchName });
|
||||
}
|
||||
|
||||
async function emitPullRequestChangeEvents(c: any, beforeRows: any[], afterRows: any[]) {
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
const beforeById = new Map(beforeRows.map((row) => [row.prId, row]));
|
||||
const afterById = new Map(afterRows.map((row) => [row.prId, row]));
|
||||
|
||||
for (const [prId, row] of afterById) {
|
||||
const previous = beforeById.get(prId);
|
||||
const changed =
|
||||
!previous ||
|
||||
previous.title !== row.title ||
|
||||
previous.state !== row.state ||
|
||||
previous.url !== row.url ||
|
||||
previous.headRefName !== row.headRefName ||
|
||||
previous.baseRefName !== row.baseRefName ||
|
||||
previous.authorLogin !== row.authorLogin ||
|
||||
previous.isDraft !== row.isDraft ||
|
||||
previous.updatedAt !== row.updatedAt;
|
||||
if (!changed) {
|
||||
continue;
|
||||
}
|
||||
await workspace.applyOpenPullRequestUpdate({
|
||||
pullRequest: pullRequestSummaryFromRow(row),
|
||||
});
|
||||
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName);
|
||||
}
|
||||
|
||||
for (const [prId, row] of beforeById) {
|
||||
if (afterById.has(prId)) {
|
||||
continue;
|
||||
}
|
||||
await workspace.removeOpenPullRequest({ prId });
|
||||
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName);
|
||||
}
|
||||
}
|
||||
|
||||
async function autoArchiveTaskForClosedPullRequest(c: any, row: any) {
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
const match = await workspace.findTaskForGithubBranch({
|
||||
repoId: row.repoId,
|
||||
branchName: row.headRefName,
|
||||
});
|
||||
if (!match?.taskId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const task = getTask(c, c.state.workspaceId, row.repoId, match.taskId);
|
||||
await task.archive({ reason: `PR ${String(row.state).toLowerCase()}` });
|
||||
} catch {
|
||||
// Best-effort only. Task summary refresh will still clear the PR state.
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRepositories(c: any, context: Awaited<ReturnType<typeof getOrganizationContext>>): Promise<GithubRepositoryRecord[]> {
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
if (context.kind === "personal") {
|
||||
if (!context.accessToken) {
|
||||
return [];
|
||||
}
|
||||
return await appShell.github.listUserRepositories(context.accessToken);
|
||||
}
|
||||
|
||||
if (context.installationId != null) {
|
||||
try {
|
||||
return await appShell.github.listInstallationRepositories(context.installationId);
|
||||
} catch (error) {
|
||||
if (!context.accessToken) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.accessToken) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (await appShell.github.listUserRepositories(context.accessToken)).filter((repository) => repository.fullName.startsWith(`${context.githubLogin}/`));
|
||||
}
|
||||
|
||||
async function resolveMembers(c: any, context: Awaited<ReturnType<typeof getOrganizationContext>>): Promise<GithubMemberRecord[]> {
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
if (context.kind === "personal") {
|
||||
return [];
|
||||
}
|
||||
if (context.installationId != null) {
|
||||
try {
|
||||
return await appShell.github.listInstallationMembers(context.installationId, context.githubLogin);
|
||||
} catch (error) {
|
||||
if (!context.accessToken) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!context.accessToken) {
|
||||
return [];
|
||||
}
|
||||
return await appShell.github.listOrganizationMembers(context.accessToken, context.githubLogin);
|
||||
}
|
||||
|
||||
async function resolvePullRequests(
|
||||
c: any,
|
||||
context: Awaited<ReturnType<typeof getOrganizationContext>>,
|
||||
repositories: GithubRepositoryRecord[],
|
||||
): Promise<GithubPullRequestRecord[]> {
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
if (repositories.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let pullRequests: Array<{
|
||||
repoFullName: string;
|
||||
cloneUrl: string;
|
||||
number: number;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
state: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
authorLogin?: string | null;
|
||||
isDraft?: boolean;
|
||||
merged?: boolean;
|
||||
}> = [];
|
||||
|
||||
if (context.installationId != null) {
|
||||
try {
|
||||
pullRequests = await appShell.github.listInstallationPullRequestsForRepositories(context.installationId, repositories);
|
||||
} catch (error) {
|
||||
if (!context.accessToken) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pullRequests.length === 0 && context.accessToken) {
|
||||
pullRequests = await appShell.github.listPullRequestsForUserRepositories(context.accessToken, repositories);
|
||||
}
|
||||
|
||||
return pullRequests.map((pullRequest) => ({
|
||||
repoId: repoIdFromRemote(pullRequest.cloneUrl),
|
||||
repoFullName: pullRequest.repoFullName,
|
||||
number: pullRequest.number,
|
||||
title: pullRequest.title,
|
||||
body: pullRequest.body ?? null,
|
||||
state: normalizePrStatus(pullRequest),
|
||||
url: pullRequest.url,
|
||||
headRefName: pullRequest.headRefName,
|
||||
baseRefName: pullRequest.baseRefName,
|
||||
authorLogin: pullRequest.authorLogin ?? null,
|
||||
isDraft: Boolean(pullRequest.isDraft),
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function readAllPullRequestRows(c: any) {
|
||||
return await c.db.select().from(githubPullRequests).all();
|
||||
}
|
||||
|
||||
async function runFullSync(c: any, input: FullSyncInput = {}) {
|
||||
const startedAt = Date.now();
|
||||
const beforeRows = await readAllPullRequestRows(c);
|
||||
const context = await getOrganizationContext(c, input);
|
||||
|
||||
await writeMeta(c, {
|
||||
connectedAccount: context.connectedAccount,
|
||||
installationStatus: context.installationStatus,
|
||||
installationId: context.installationId,
|
||||
syncStatus: "syncing",
|
||||
lastSyncLabel: input.label?.trim() || "Syncing GitHub data...",
|
||||
});
|
||||
|
||||
const repositories = await resolveRepositories(c, context);
|
||||
const members = await resolveMembers(c, context);
|
||||
const pullRequests = await resolvePullRequests(c, context, repositories);
|
||||
|
||||
await replaceRepositories(c, repositories, startedAt);
|
||||
await replaceMembers(c, members, startedAt);
|
||||
await replacePullRequests(c, pullRequests);
|
||||
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.applyGithubDataProjection({
|
||||
connectedAccount: context.connectedAccount,
|
||||
installationStatus: context.installationStatus,
|
||||
installationId: context.installationId,
|
||||
syncStatus: "synced",
|
||||
lastSyncLabel: repositories.length > 0 ? `Synced ${repositories.length} repositories` : "No repositories available",
|
||||
lastSyncAt: startedAt,
|
||||
repositories,
|
||||
});
|
||||
|
||||
const meta = await writeMeta(c, {
|
||||
connectedAccount: context.connectedAccount,
|
||||
installationStatus: context.installationStatus,
|
||||
installationId: context.installationId,
|
||||
syncStatus: "synced",
|
||||
lastSyncLabel: repositories.length > 0 ? `Synced ${repositories.length} repositories` : "No repositories available",
|
||||
lastSyncAt: startedAt,
|
||||
});
|
||||
|
||||
const afterRows = await readAllPullRequestRows(c);
|
||||
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
|
||||
|
||||
return {
|
||||
...meta,
|
||||
repositoryCount: repositories.length,
|
||||
memberCount: members.length,
|
||||
pullRequestCount: afterRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
export const githubData = actor({
|
||||
db: githubDataDb,
|
||||
options: {
|
||||
name: "GitHub Data",
|
||||
icon: "github",
|
||||
actionTimeout: 5 * 60_000,
|
||||
},
|
||||
createState: (_c, input: GithubDataInput) => ({
|
||||
workspaceId: input.workspaceId,
|
||||
}),
|
||||
actions: {
|
||||
async getSummary(c) {
|
||||
const repositories = await c.db.select().from(githubRepositories).all();
|
||||
const members = await c.db.select().from(githubMembers).all();
|
||||
const pullRequests = await c.db.select().from(githubPullRequests).all();
|
||||
return {
|
||||
...(await readMeta(c)),
|
||||
repositoryCount: repositories.length,
|
||||
memberCount: members.length,
|
||||
pullRequestCount: pullRequests.length,
|
||||
};
|
||||
},
|
||||
|
||||
async listRepositories(c) {
|
||||
const rows = await c.db.select().from(githubRepositories).all();
|
||||
return rows.map((row) => ({
|
||||
repoId: row.repoId,
|
||||
fullName: row.fullName,
|
||||
cloneUrl: row.cloneUrl,
|
||||
private: Boolean(row.private),
|
||||
}));
|
||||
},
|
||||
|
||||
async listPullRequestsForRepository(c, input: { repoId: string }) {
|
||||
const rows = await c.db.select().from(githubPullRequests).where(eq(githubPullRequests.repoId, input.repoId)).all();
|
||||
return rows.map(pullRequestSummaryFromRow);
|
||||
},
|
||||
|
||||
async listOpenPullRequests(c) {
|
||||
const rows = await c.db.select().from(githubPullRequests).all();
|
||||
return rows.map(pullRequestSummaryFromRow).sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||
},
|
||||
|
||||
async getPullRequestForBranch(c, input: { repoId: string; branchName: string }) {
|
||||
const rows = await c.db.select().from(githubPullRequests).where(eq(githubPullRequests.repoId, input.repoId)).all();
|
||||
const match = rows.find((candidate) => candidate.headRefName === input.branchName) ?? null;
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
number: match.number,
|
||||
status: match.isDraft ? ("draft" as const) : ("ready" as const),
|
||||
};
|
||||
},
|
||||
|
||||
async fullSync(c, input: FullSyncInput = {}) {
|
||||
return await runFullSync(c, input);
|
||||
},
|
||||
|
||||
async reloadOrganization(c) {
|
||||
return await runFullSync(c, { label: "Reloading GitHub organization..." });
|
||||
},
|
||||
|
||||
async reloadAllPullRequests(c) {
|
||||
return await runFullSync(c, { label: "Reloading GitHub pull requests..." });
|
||||
},
|
||||
|
||||
async reloadRepository(c, input: { repoId: string }) {
|
||||
const context = await getOrganizationContext(c);
|
||||
const current = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, input.repoId)).get();
|
||||
if (!current) {
|
||||
throw new Error(`Unknown GitHub repository: ${input.repoId}`);
|
||||
}
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const repository =
|
||||
context.installationId != null
|
||||
? await appShell.github.getInstallationRepository(context.installationId, current.fullName)
|
||||
: context.accessToken
|
||||
? await appShell.github.getUserRepository(context.accessToken, current.fullName)
|
||||
: null;
|
||||
if (!repository) {
|
||||
throw new Error(`Unable to reload repository: ${current.fullName}`);
|
||||
}
|
||||
|
||||
const updatedAt = Date.now();
|
||||
await c.db
|
||||
.insert(githubRepositories)
|
||||
.values({
|
||||
repoId: input.repoId,
|
||||
fullName: repository.fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
private: repository.private ? 1 : 0,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: githubRepositories.repoId,
|
||||
set: {
|
||||
fullName: repository.fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
private: repository.private ? 1 : 0,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.applyGithubRepositoryProjection({
|
||||
repoId: input.repoId,
|
||||
remoteUrl: repository.cloneUrl,
|
||||
});
|
||||
return {
|
||||
repoId: input.repoId,
|
||||
fullName: repository.fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
private: repository.private,
|
||||
};
|
||||
},
|
||||
|
||||
async reloadPullRequest(c, input: { repoId: string; prNumber: number }) {
|
||||
const repository = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, input.repoId)).get();
|
||||
if (!repository) {
|
||||
throw new Error(`Unknown GitHub repository: ${input.repoId}`);
|
||||
}
|
||||
const context = await getOrganizationContext(c);
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const pullRequest =
|
||||
context.installationId != null
|
||||
? await appShell.github.getInstallationPullRequest(context.installationId, repository.fullName, input.prNumber)
|
||||
: context.accessToken
|
||||
? await appShell.github.getUserPullRequest(context.accessToken, repository.fullName, input.prNumber)
|
||||
: null;
|
||||
if (!pullRequest) {
|
||||
throw new Error(`Unable to reload pull request #${input.prNumber} for ${repository.fullName}`);
|
||||
}
|
||||
|
||||
const beforeRows = await readAllPullRequestRows(c);
|
||||
const updatedAt = Date.now();
|
||||
const nextState = normalizePrStatus(pullRequest);
|
||||
const prId = `${input.repoId}#${input.prNumber}`;
|
||||
if (nextState === "CLOSED" || nextState === "MERGED") {
|
||||
await c.db.delete(githubPullRequests).where(eq(githubPullRequests.prId, prId)).run();
|
||||
} else {
|
||||
await c.db
|
||||
.insert(githubPullRequests)
|
||||
.values({
|
||||
prId,
|
||||
repoId: input.repoId,
|
||||
repoFullName: repository.fullName,
|
||||
number: pullRequest.number,
|
||||
title: pullRequest.title,
|
||||
body: pullRequest.body ?? null,
|
||||
state: nextState,
|
||||
url: pullRequest.url,
|
||||
headRefName: pullRequest.headRefName,
|
||||
baseRefName: pullRequest.baseRefName,
|
||||
authorLogin: pullRequest.authorLogin ?? null,
|
||||
isDraft: pullRequest.isDraft ? 1 : 0,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: githubPullRequests.prId,
|
||||
set: {
|
||||
title: pullRequest.title,
|
||||
body: pullRequest.body ?? null,
|
||||
state: nextState,
|
||||
url: pullRequest.url,
|
||||
headRefName: pullRequest.headRefName,
|
||||
baseRefName: pullRequest.baseRefName,
|
||||
authorLogin: pullRequest.authorLogin ?? null,
|
||||
isDraft: pullRequest.isDraft ? 1 : 0,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
const afterRows = await readAllPullRequestRows(c);
|
||||
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
|
||||
const closed = afterRows.find((row) => row.prId === prId);
|
||||
if (!closed && (nextState === "CLOSED" || nextState === "MERGED")) {
|
||||
const previous = beforeRows.find((row) => row.prId === prId);
|
||||
if (previous) {
|
||||
await autoArchiveTaskForClosedPullRequest(c, {
|
||||
...previous,
|
||||
state: nextState,
|
||||
});
|
||||
}
|
||||
}
|
||||
return pullRequestSummaryFromRow(
|
||||
afterRows.find((row) => row.prId === prId) ?? {
|
||||
prId,
|
||||
repoId: input.repoId,
|
||||
repoFullName: repository.fullName,
|
||||
number: input.prNumber,
|
||||
title: pullRequest.title,
|
||||
state: nextState,
|
||||
url: pullRequest.url,
|
||||
headRefName: pullRequest.headRefName,
|
||||
baseRefName: pullRequest.baseRefName,
|
||||
authorLogin: pullRequest.authorLogin ?? null,
|
||||
isDraft: pullRequest.isDraft ? 1 : 0,
|
||||
updatedAt,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async clearState(c, input: ClearStateInput) {
|
||||
const beforeRows = await readAllPullRequestRows(c);
|
||||
await c.db.delete(githubPullRequests).run();
|
||||
await c.db.delete(githubRepositories).run();
|
||||
await c.db.delete(githubMembers).run();
|
||||
await writeMeta(c, {
|
||||
connectedAccount: input.connectedAccount,
|
||||
installationStatus: input.installationStatus,
|
||||
installationId: input.installationId,
|
||||
syncStatus: "pending",
|
||||
lastSyncLabel: input.label,
|
||||
lastSyncAt: null,
|
||||
});
|
||||
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.applyGithubDataProjection({
|
||||
connectedAccount: input.connectedAccount,
|
||||
installationStatus: input.installationStatus,
|
||||
installationId: input.installationId,
|
||||
syncStatus: "pending",
|
||||
lastSyncLabel: input.label,
|
||||
lastSyncAt: null,
|
||||
repositories: [],
|
||||
});
|
||||
await emitPullRequestChangeEvents(c, beforeRows, []);
|
||||
},
|
||||
|
||||
async handlePullRequestWebhook(c, input: PullRequestWebhookInput) {
|
||||
const beforeRows = await readAllPullRequestRows(c);
|
||||
const repoId = repoIdFromRemote(input.repository.cloneUrl);
|
||||
const updatedAt = Date.now();
|
||||
const state = normalizePrStatus(input.pullRequest);
|
||||
const prId = `${repoId}#${input.pullRequest.number}`;
|
||||
|
||||
await c.db
|
||||
.insert(githubRepositories)
|
||||
.values({
|
||||
repoId,
|
||||
fullName: input.repository.fullName,
|
||||
cloneUrl: input.repository.cloneUrl,
|
||||
private: input.repository.private ? 1 : 0,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: githubRepositories.repoId,
|
||||
set: {
|
||||
fullName: input.repository.fullName,
|
||||
cloneUrl: input.repository.cloneUrl,
|
||||
private: input.repository.private ? 1 : 0,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
if (state === "CLOSED" || state === "MERGED") {
|
||||
await c.db.delete(githubPullRequests).where(eq(githubPullRequests.prId, prId)).run();
|
||||
} else {
|
||||
await c.db
|
||||
.insert(githubPullRequests)
|
||||
.values({
|
||||
prId,
|
||||
repoId,
|
||||
repoFullName: input.repository.fullName,
|
||||
number: input.pullRequest.number,
|
||||
title: input.pullRequest.title,
|
||||
body: input.pullRequest.body ?? null,
|
||||
state,
|
||||
url: input.pullRequest.url,
|
||||
headRefName: input.pullRequest.headRefName,
|
||||
baseRefName: input.pullRequest.baseRefName,
|
||||
authorLogin: input.pullRequest.authorLogin ?? null,
|
||||
isDraft: input.pullRequest.isDraft ? 1 : 0,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: githubPullRequests.prId,
|
||||
set: {
|
||||
title: input.pullRequest.title,
|
||||
body: input.pullRequest.body ?? null,
|
||||
state,
|
||||
url: input.pullRequest.url,
|
||||
headRefName: input.pullRequest.headRefName,
|
||||
baseRefName: input.pullRequest.baseRefName,
|
||||
authorLogin: input.pullRequest.authorLogin ?? null,
|
||||
isDraft: input.pullRequest.isDraft ? 1 : 0,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
await writeMeta(c, {
|
||||
connectedAccount: input.connectedAccount,
|
||||
installationStatus: input.installationStatus,
|
||||
installationId: input.installationId,
|
||||
syncStatus: "synced",
|
||||
lastSyncLabel: "GitHub webhook received",
|
||||
lastSyncAt: updatedAt,
|
||||
});
|
||||
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.applyGithubRepositoryProjection({
|
||||
repoId,
|
||||
remoteUrl: input.repository.cloneUrl,
|
||||
});
|
||||
|
||||
const afterRows = await readAllPullRequestRows(c);
|
||||
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
|
||||
if (state === "CLOSED" || state === "MERGED") {
|
||||
const previous = beforeRows.find((row) => row.prId === prId);
|
||||
if (previous) {
|
||||
await autoArchiveTaskForClosedPullRequest(c, {
|
||||
...previous,
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { authUserKey, taskKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, taskSandboxKey, workspaceKey } from "./keys.js";
|
||||
import { authUserKey, githubDataKey, taskKey, historyKey, projectBranchSyncKey, projectKey, taskSandboxKey, workspaceKey } from "./keys.js";
|
||||
|
||||
export function actorClient(c: any) {
|
||||
return c.client();
|
||||
|
|
@ -53,17 +53,18 @@ export async function getOrCreateHistory(c: any, workspaceId: string, repoId: st
|
|||
});
|
||||
}
|
||||
|
||||
export async function getOrCreateProjectPrSync(c: any, workspaceId: string, repoId: string, repoPath: string, intervalMs: number) {
|
||||
return await actorClient(c).projectPrSync.getOrCreate(projectPrSyncKey(workspaceId, repoId), {
|
||||
export async function getOrCreateGithubData(c: any, workspaceId: string) {
|
||||
return await actorClient(c).githubData.getOrCreate(githubDataKey(workspaceId), {
|
||||
createWithInput: {
|
||||
workspaceId,
|
||||
repoId,
|
||||
repoPath,
|
||||
intervalMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getGithubData(c: any, workspaceId: string) {
|
||||
return actorClient(c).githubData.get(githubDataKey(workspaceId));
|
||||
}
|
||||
|
||||
export async function getOrCreateProjectBranchSync(c: any, workspaceId: string, repoId: string, repoPath: string, intervalMs: number) {
|
||||
return await actorClient(c).projectBranchSync.getOrCreate(projectBranchSyncKey(workspaceId, repoId), {
|
||||
createWithInput: {
|
||||
|
|
@ -85,10 +86,6 @@ export async function getOrCreateTaskSandbox(c: any, workspaceId: string, sandbo
|
|||
});
|
||||
}
|
||||
|
||||
export function selfProjectPrSync(c: any) {
|
||||
return actorClient(c).projectPrSync.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfProjectBranchSync(c: any) {
|
||||
return actorClient(c).projectBranchSync.getForId(c.actorId);
|
||||
}
|
||||
|
|
@ -112,3 +109,7 @@ export function selfProject(c: any) {
|
|||
export function selfAuthUser(c: any) {
|
||||
return actorClient(c).authUser.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfGithubData(c: any) {
|
||||
return actorClient(c).githubData.getForId(c.actorId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { authUser } from "./auth-user/index.js";
|
||||
import { setup } from "rivetkit";
|
||||
import { githubData } from "./github-data/index.js";
|
||||
import { task } from "./task/index.js";
|
||||
import { history } from "./history/index.js";
|
||||
import { projectBranchSync } from "./project-branch-sync/index.js";
|
||||
import { projectPrSync } from "./project-pr-sync/index.js";
|
||||
import { project } from "./project/index.js";
|
||||
import { taskSandbox } from "./sandbox/index.js";
|
||||
import { workspace } from "./workspace/index.js";
|
||||
|
|
@ -28,7 +28,7 @@ export const registry = setup({
|
|||
task,
|
||||
taskSandbox,
|
||||
history,
|
||||
projectPrSync,
|
||||
githubData,
|
||||
projectBranchSync,
|
||||
},
|
||||
});
|
||||
|
|
@ -36,11 +36,11 @@ export const registry = setup({
|
|||
export * from "./context.js";
|
||||
export * from "./events.js";
|
||||
export * from "./auth-user/index.js";
|
||||
export * from "./github-data/index.js";
|
||||
export * from "./task/index.js";
|
||||
export * from "./history/index.js";
|
||||
export * from "./keys.js";
|
||||
export * from "./project-branch-sync/index.js";
|
||||
export * from "./project-pr-sync/index.js";
|
||||
export * from "./project/index.js";
|
||||
export * from "./sandbox/index.js";
|
||||
export * from "./workspace/index.js";
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ export function historyKey(workspaceId: string, repoId: string): ActorKey {
|
|||
return ["ws", workspaceId, "project", repoId, "history"];
|
||||
}
|
||||
|
||||
export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "pr-sync"];
|
||||
export function githubDataKey(workspaceId: string): ActorKey {
|
||||
return ["ws", workspaceId, "github-data"];
|
||||
}
|
||||
|
||||
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getProject, selfProjectPrSync } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
|
||||
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
|
||||
export interface ProjectPrSyncInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoPath: string;
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface SetIntervalCommand {
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface ProjectPrSyncState extends PollingControlState {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
const CONTROL = {
|
||||
start: "project.pr_sync.control.start",
|
||||
stop: "project.pr_sync.control.stop",
|
||||
setInterval: "project.pr_sync.control.set_interval",
|
||||
force: "project.pr_sync.control.force",
|
||||
} as const;
|
||||
|
||||
async function pollPrs(c: { state: ProjectPrSyncState }): Promise<void> {
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
||||
const items = await driver.github.listPullRequests(c.state.repoPath, { githubToken: auth?.githubToken ?? null });
|
||||
const parent = getProject(c, c.state.workspaceId, c.state.repoId);
|
||||
await parent.applyPrSyncResult({ items, at: Date.now() });
|
||||
}
|
||||
|
||||
export const projectPrSync = actor({
|
||||
queues: {
|
||||
[CONTROL.start]: queue(),
|
||||
[CONTROL.stop]: queue(),
|
||||
[CONTROL.setInterval]: queue(),
|
||||
[CONTROL.force]: queue(),
|
||||
},
|
||||
options: {
|
||||
name: "Project PR Sync",
|
||||
icon: "code-merge",
|
||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true,
|
||||
},
|
||||
createState: (_c, input: ProjectPrSyncInput): ProjectPrSyncState => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
repoPath: input.repoPath,
|
||||
intervalMs: input.intervalMs,
|
||||
running: true,
|
||||
}),
|
||||
actions: {
|
||||
async start(c): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async stop(c): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async force(c): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
||||
},
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await runWorkflowPollingLoop<ProjectPrSyncState>(ctx, {
|
||||
loopName: "project-pr-sync-loop",
|
||||
control: CONTROL,
|
||||
onPoll: async (loopCtx) => {
|
||||
try {
|
||||
await pollPrs(loopCtx);
|
||||
} catch (error) {
|
||||
logActorWarning("project-pr-sync", "poll failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
stack: resolveErrorStack(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
@ -4,13 +4,13 @@ import { and, desc, eq, isNotNull, ne } from "drizzle-orm";
|
|||
import { Loop } from "rivetkit/workflow";
|
||||
import type { AgentType, TaskRecord, TaskSummary, ProviderId, RepoOverview, RepoStackAction, RepoStackActionResult } from "@sandbox-agent/foundry-shared";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getTask, getOrCreateTask, getOrCreateHistory, getOrCreateProjectBranchSync, getOrCreateProjectPrSync, selfProject } from "../handles.js";
|
||||
import { getGithubData, getTask, getOrCreateTask, getOrCreateHistory, getOrCreateProjectBranchSync, selfProject } from "../handles.js";
|
||||
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { foundryRepoClonePath } from "../../services/foundry-paths.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { withRepoGitLock } from "../../services/repo-git-lock.js";
|
||||
import { branches, taskIndex, prCache, repoActionJobs, repoMeta } from "./db/schema.js";
|
||||
import { branches, taskIndex, repoActionJobs, repoMeta } from "./db/schema.js";
|
||||
import { deriveFallbackTitle } from "../../services/create-flow.js";
|
||||
import { normalizeBaseBranchName } from "../../integrations/git-spice/index.js";
|
||||
import { sortBranchesForOverview } from "./stack-model.js";
|
||||
|
|
@ -55,22 +55,6 @@ interface GetPullRequestForBranchCommand {
|
|||
branchName: string;
|
||||
}
|
||||
|
||||
interface PrSyncResult {
|
||||
items: Array<{
|
||||
number: number;
|
||||
headRefName: string;
|
||||
state: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
author?: string;
|
||||
isDraft?: boolean;
|
||||
ciStatus?: string | null;
|
||||
reviewStatus?: string | null;
|
||||
reviewer?: string | null;
|
||||
}>;
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface BranchSyncResult {
|
||||
items: Array<{
|
||||
branchName: string;
|
||||
|
|
@ -99,7 +83,6 @@ const PROJECT_QUEUE_NAMES = [
|
|||
"project.command.createTask",
|
||||
"project.command.registerTaskBranch",
|
||||
"project.command.runRepoStackAction",
|
||||
"project.command.applyPrSyncResult",
|
||||
"project.command.applyBranchSyncResult",
|
||||
] as const;
|
||||
|
||||
|
|
@ -125,18 +108,9 @@ async function ensureProjectSyncActors(c: any, localPath: string): Promise<void>
|
|||
return;
|
||||
}
|
||||
|
||||
const prSync = await getOrCreateProjectPrSync(c, c.state.workspaceId, c.state.repoId, localPath, 30_000);
|
||||
const branchSync = await getOrCreateProjectBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000);
|
||||
c.state.syncActorsStarted = true;
|
||||
|
||||
void prSync.start().catch((error: unknown) => {
|
||||
logActorWarning("project.sync", "starting pr sync actor failed", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
});
|
||||
|
||||
void branchSync.start().catch((error: unknown) => {
|
||||
logActorWarning("project.sync", "starting branch sync actor failed", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
|
|
@ -352,9 +326,6 @@ async function ensureTaskIndexHydratedForRead(c: any): Promise<void> {
|
|||
}
|
||||
|
||||
async function forceProjectSync(c: any, localPath: string): Promise<void> {
|
||||
const prSync = await getOrCreateProjectPrSync(c, c.state.workspaceId, c.state.repoId, localPath, 30_000);
|
||||
await prSync.force();
|
||||
|
||||
const branchSync = await getOrCreateProjectBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000);
|
||||
await branchSync.force();
|
||||
}
|
||||
|
|
@ -377,17 +348,10 @@ async function enrichTaskRecord(c: any, record: TaskRecord): Promise<TaskRecord>
|
|||
|
||||
const pr =
|
||||
branchName != null
|
||||
? await c.db
|
||||
.select({
|
||||
prUrl: prCache.prUrl,
|
||||
prAuthor: prCache.prAuthor,
|
||||
ciStatus: prCache.ciStatus,
|
||||
reviewStatus: prCache.reviewStatus,
|
||||
reviewer: prCache.reviewer,
|
||||
})
|
||||
.from(prCache)
|
||||
.where(eq(prCache.branchName, branchName))
|
||||
.get()
|
||||
? await getGithubData(c, c.state.workspaceId)
|
||||
.listPullRequestsForRepository({ repoId: c.state.repoId })
|
||||
.then((rows: any[]) => rows.find((row) => row.headRefName === branchName) ?? null)
|
||||
.catch(() => null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
|
@ -396,11 +360,11 @@ async function enrichTaskRecord(c: any, record: TaskRecord): Promise<TaskRecord>
|
|||
hasUnpushed: br?.hasUnpushed != null ? String(br.hasUnpushed) : null,
|
||||
conflictsWithMain: br?.conflictsWithMain != null ? String(br.conflictsWithMain) : null,
|
||||
parentBranch: br?.parentBranch ?? null,
|
||||
prUrl: pr?.prUrl ?? null,
|
||||
prAuthor: pr?.prAuthor ?? null,
|
||||
ciStatus: pr?.ciStatus ?? null,
|
||||
reviewStatus: pr?.reviewStatus ?? null,
|
||||
reviewer: pr?.reviewer ?? null,
|
||||
prUrl: pr?.url ?? null,
|
||||
prAuthor: pr?.authorLogin ?? null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: pr?.authorLogin ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -458,11 +422,6 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
|
|||
const taskId = randomUUID();
|
||||
|
||||
if (onBranch) {
|
||||
const branchRow = await c.db.select({ branchName: branches.branchName }).from(branches).where(eq(branches.branchName, onBranch)).get();
|
||||
if (!branchRow) {
|
||||
throw new Error(`Branch not found in repo snapshot: ${onBranch}`);
|
||||
}
|
||||
|
||||
await registerTaskBranchMutation(c, {
|
||||
taskId,
|
||||
branchName: onBranch,
|
||||
|
|
@ -810,82 +769,6 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
|
|||
};
|
||||
}
|
||||
|
||||
async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<void> {
|
||||
await c.db.delete(prCache).run();
|
||||
|
||||
for (const item of body.items) {
|
||||
await c.db
|
||||
.insert(prCache)
|
||||
.values({
|
||||
branchName: item.headRefName,
|
||||
prNumber: item.number,
|
||||
state: item.state,
|
||||
title: item.title,
|
||||
prUrl: item.url ?? null,
|
||||
prAuthor: item.author ?? null,
|
||||
isDraft: item.isDraft ? 1 : 0,
|
||||
ciStatus: item.ciStatus ?? null,
|
||||
reviewStatus: item.reviewStatus ?? null,
|
||||
reviewer: item.reviewer ?? null,
|
||||
fetchedAt: body.at,
|
||||
updatedAt: body.at,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: prCache.branchName,
|
||||
set: {
|
||||
prNumber: item.number,
|
||||
state: item.state,
|
||||
title: item.title,
|
||||
prUrl: item.url ?? null,
|
||||
prAuthor: item.author ?? null,
|
||||
isDraft: item.isDraft ? 1 : 0,
|
||||
ciStatus: item.ciStatus ?? null,
|
||||
reviewStatus: item.reviewStatus ?? null,
|
||||
reviewer: item.reviewer ?? null,
|
||||
fetchedAt: body.at,
|
||||
updatedAt: body.at,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
for (const item of body.items) {
|
||||
if (item.state !== "MERGED" && item.state !== "CLOSED") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.branchName, item.headRefName)).get();
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const h = getTask(c, c.state.workspaceId, c.state.repoId, row.taskId);
|
||||
await h.archive({ reason: `PR ${item.state.toLowerCase()}` });
|
||||
} catch (error) {
|
||||
if (isStaleTaskReferenceError(error)) {
|
||||
await deleteStaleTaskIndexRow(c, row.taskId);
|
||||
logActorWarning("project", "pruned stale task index row during PR close archive", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
taskId: row.taskId,
|
||||
branchName: item.headRefName,
|
||||
prState: item.state,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
logActorWarning("project", "failed to auto-archive task after PR close", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
taskId: row.taskId,
|
||||
branchName: item.headRefName,
|
||||
prState: item.state,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applyBranchSyncResultMutation(c: any, body: BranchSyncResult): Promise<void> {
|
||||
const incoming = new Set(body.items.map((item) => item.branchName));
|
||||
const reservedRows = await c.db.select({ branchName: taskIndex.branchName }).from(taskIndex).where(isNotNull(taskIndex.branchName)).all();
|
||||
|
|
@ -953,69 +836,77 @@ export async function runProjectWorkflow(ctx: any): Promise<void> {
|
|||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.ensure") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-ensure",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => ensureProjectMutation(loopCtx, msg.body as EnsureProjectCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
try {
|
||||
if (msg.name === "project.command.ensure") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-ensure",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => ensureProjectMutation(loopCtx, msg.body as EnsureProjectCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.hydrateTaskIndex") {
|
||||
await loopCtx.step("project-hydrate-task-index", async () => hydrateTaskIndexMutation(loopCtx, msg.body as HydrateTaskIndexCommand));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "project.command.hydrateTaskIndex") {
|
||||
await loopCtx.step("project-hydrate-task-index", async () => hydrateTaskIndexMutation(loopCtx, msg.body as HydrateTaskIndexCommand));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.createTask") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-create-task",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "project.command.createTask") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-create-task",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.registerTaskBranch") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-register-task-branch",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => registerTaskBranchMutation(loopCtx, msg.body as RegisterTaskBranchCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "project.command.registerTaskBranch") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-register-task-branch",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => registerTaskBranchMutation(loopCtx, msg.body as RegisterTaskBranchCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.runRepoStackAction") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-run-repo-stack-action",
|
||||
timeout: 12 * 60_000,
|
||||
run: async () => runRepoStackActionMutation(loopCtx, msg.body as RunRepoStackActionCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "project.command.runRepoStackAction") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-run-repo-stack-action",
|
||||
timeout: 12 * 60_000,
|
||||
run: async () => runRepoStackActionMutation(loopCtx, msg.body as RunRepoStackActionCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.applyPrSyncResult") {
|
||||
await loopCtx.step({
|
||||
name: "project-apply-pr-sync-result",
|
||||
timeout: 60_000,
|
||||
run: async () => applyPrSyncResultMutation(loopCtx, msg.body as PrSyncResult),
|
||||
if (msg.name === "project.command.applyBranchSyncResult") {
|
||||
await loopCtx.step({
|
||||
name: "project-apply-branch-sync-result",
|
||||
timeout: 60_000,
|
||||
run: async () => applyBranchSyncResultMutation(loopCtx, msg.body as BranchSyncResult),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = resolveErrorMessage(error);
|
||||
logActorWarning("project", "project workflow command failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
queueName: msg.name,
|
||||
error: message,
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.applyBranchSyncResult") {
|
||||
await loopCtx.step({
|
||||
name: "project-apply-branch-sync-result",
|
||||
timeout: 60_000,
|
||||
run: async () => applyBranchSyncResultMutation(loopCtx, msg.body as BranchSyncResult),
|
||||
await msg.complete({ error: message }).catch((completeError: unknown) => {
|
||||
logActorWarning("project", "project workflow failed completing error response", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
queueName: msg.name,
|
||||
error: resolveErrorMessage(completeError),
|
||||
});
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
return Loop.continue(undefined);
|
||||
|
|
@ -1219,19 +1110,9 @@ export const projectActions = {
|
|||
}
|
||||
}
|
||||
|
||||
const prRows = await c.db
|
||||
.select({
|
||||
branchName: prCache.branchName,
|
||||
prNumber: prCache.prNumber,
|
||||
prState: prCache.state,
|
||||
prUrl: prCache.prUrl,
|
||||
ciStatus: prCache.ciStatus,
|
||||
reviewStatus: prCache.reviewStatus,
|
||||
reviewer: prCache.reviewer,
|
||||
})
|
||||
.from(prCache)
|
||||
.all();
|
||||
const prByBranch = new Map(prRows.map((row) => [row.branchName, row]));
|
||||
const githubData = getGithubData(c, c.state.workspaceId);
|
||||
const prRows = await githubData.listPullRequestsForRepository({ repoId: c.state.repoId }).catch(() => []);
|
||||
const prByBranch = new Map(prRows.map((row) => [row.headRefName, row]));
|
||||
|
||||
const combinedRows = sortBranchesForOverview(
|
||||
branchRowsRaw.map((row) => ({
|
||||
|
|
@ -1258,12 +1139,12 @@ export const projectActions = {
|
|||
taskId: taskMeta?.taskId ?? null,
|
||||
taskTitle: taskMeta?.title ?? null,
|
||||
taskStatus: taskMeta?.status ?? null,
|
||||
prNumber: pr?.prNumber ?? null,
|
||||
prState: pr?.prState ?? null,
|
||||
prUrl: pr?.prUrl ?? null,
|
||||
ciStatus: pr?.ciStatus ?? null,
|
||||
reviewStatus: pr?.reviewStatus ?? null,
|
||||
reviewer: pr?.reviewer ?? null,
|
||||
prNumber: pr?.number ?? null,
|
||||
prState: pr?.state ?? null,
|
||||
prUrl: pr?.url ?? null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: pr?.authorLogin ?? null,
|
||||
firstSeenAt: row.firstSeenAt ?? null,
|
||||
lastSeenAt: row.lastSeenAt ?? null,
|
||||
updatedAt: Math.max(row.updatedAt, taskMeta?.updatedAt ?? 0),
|
||||
|
|
@ -1271,7 +1152,7 @@ export const projectActions = {
|
|||
});
|
||||
|
||||
const latestBranchSync = await c.db.select({ updatedAt: branches.updatedAt }).from(branches).orderBy(desc(branches.updatedAt)).limit(1).get();
|
||||
const latestPrSync = await c.db.select({ updatedAt: prCache.updatedAt }).from(prCache).orderBy(desc(prCache.updatedAt)).limit(1).get();
|
||||
const githubSummary = await githubData.getSummary().catch(() => null);
|
||||
|
||||
return {
|
||||
workspaceId: c.state.workspaceId,
|
||||
|
|
@ -1281,9 +1162,9 @@ export const projectActions = {
|
|||
stackAvailable,
|
||||
fetchedAt: now,
|
||||
branchSyncAt: latestBranchSync?.updatedAt ?? null,
|
||||
prSyncAt: latestPrSync?.updatedAt ?? null,
|
||||
prSyncAt: githubSummary?.lastSyncAt ?? null,
|
||||
branchSyncStatus: latestBranchSync ? "synced" : "pending",
|
||||
prSyncStatus: latestPrSync ? "synced" : "pending",
|
||||
prSyncStatus: githubSummary?.syncStatus ?? "pending",
|
||||
repoActionJobs: await listRepoActionJobRows(c),
|
||||
branches: branchRows,
|
||||
};
|
||||
|
|
@ -1294,24 +1175,11 @@ export const projectActions = {
|
|||
if (!branchName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pr = await c.db
|
||||
.select({
|
||||
prNumber: prCache.prNumber,
|
||||
prState: prCache.state,
|
||||
})
|
||||
.from(prCache)
|
||||
.where(eq(prCache.branchName, branchName))
|
||||
.get();
|
||||
|
||||
if (!pr?.prNumber) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
number: pr.prNumber,
|
||||
status: pr.prState === "draft" ? "draft" : "ready",
|
||||
};
|
||||
const githubData = getGithubData(c, c.state.workspaceId);
|
||||
return await githubData.getPullRequestForBranch({
|
||||
repoId: c.state.repoId,
|
||||
branchName,
|
||||
});
|
||||
},
|
||||
|
||||
async runRepoStackAction(c: any, cmd: RunRepoStackActionCommand): Promise<RepoStackActionResult> {
|
||||
|
|
@ -1353,14 +1221,6 @@ export const projectActions = {
|
|||
};
|
||||
},
|
||||
|
||||
async applyPrSyncResult(c: any, body: PrSyncResult): Promise<void> {
|
||||
const self = selfProject(c);
|
||||
await self.send(projectWorkflowQueueName("project.command.applyPrSyncResult"), body, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async applyBranchSyncResult(c: any, body: BranchSyncResult): Promise<void> {
|
||||
const self = selfProject(c);
|
||||
await self.send(projectWorkflowQueueName("project.command.applyBranchSyncResult"), body, {
|
||||
|
|
|
|||
|
|
@ -29,21 +29,6 @@ export default {
|
|||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`pr_cache\` (
|
||||
\`branch_name\` text PRIMARY KEY NOT NULL,
|
||||
\`pr_number\` integer NOT NULL,
|
||||
\`state\` text NOT NULL,
|
||||
\`title\` text NOT NULL,
|
||||
\`pr_url\` text,
|
||||
\`pr_author\` text,
|
||||
\`is_draft\` integer DEFAULT 0 NOT NULL,
|
||||
\`ci_status\` text,
|
||||
\`review_status\` text,
|
||||
\`reviewer\` text,
|
||||
\`fetched_at\` integer,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`repo_meta\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`remote_url\` text NOT NULL,
|
||||
|
|
|
|||
|
|
@ -21,21 +21,6 @@ export const repoMeta = sqliteTable("repo_meta", {
|
|||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const prCache = sqliteTable("pr_cache", {
|
||||
branchName: text("branch_name").notNull().primaryKey(),
|
||||
prNumber: integer("pr_number").notNull(),
|
||||
state: text("state").notNull(),
|
||||
title: text("title").notNull(),
|
||||
prUrl: text("pr_url"),
|
||||
prAuthor: text("pr_author"),
|
||||
isDraft: integer("is_draft").notNull().default(0),
|
||||
ciStatus: text("ci_status"),
|
||||
reviewStatus: text("review_status"),
|
||||
reviewer: text("reviewer"),
|
||||
fetchedAt: integer("fetched_at"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const taskIndex = sqliteTable("task_index", {
|
||||
taskId: text("task_id").notNull().primaryKey(),
|
||||
branchName: text("branch_name"),
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@ interface TaskWorkbenchSendMessageCommand {
|
|||
attachments: Array<any>;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSendMessageActionInput extends TaskWorkbenchSendMessageInput {
|
||||
waitForCompletion?: boolean;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchCreateSessionCommand {
|
||||
model?: string;
|
||||
}
|
||||
|
|
@ -317,9 +321,9 @@ export const task = actor({
|
|||
);
|
||||
},
|
||||
|
||||
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageInput): Promise<void> {
|
||||
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageActionInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
const result = await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.send_message"),
|
||||
{
|
||||
sessionId: input.tabId,
|
||||
|
|
@ -327,9 +331,13 @@ export const task = actor({
|
|||
attachments: input.attachments,
|
||||
} satisfies TaskWorkbenchSendMessageCommand,
|
||||
{
|
||||
wait: false,
|
||||
wait: input.waitForCompletion === true,
|
||||
...(input.waitForCompletion === true ? { timeout: 10 * 60_000 } : {}),
|
||||
},
|
||||
);
|
||||
if (input.waitForCompletion === true) {
|
||||
expectQueueResponse(result);
|
||||
}
|
||||
},
|
||||
|
||||
async stopWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -149,6 +149,23 @@ export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: numbe
|
|||
return Boolean(meta.thinkingSinceMs);
|
||||
}
|
||||
|
||||
export function shouldRecreateSessionForModelChange(meta: {
|
||||
status: "pending_provision" | "pending_session_create" | "ready" | "error";
|
||||
sandboxSessionId?: string | null;
|
||||
created?: boolean;
|
||||
transcript?: Array<any>;
|
||||
}): boolean {
|
||||
if (meta.status !== "ready" || !meta.sandboxSessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (meta.created) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !Array.isArray(meta.transcript) || meta.transcript.length === 0;
|
||||
}
|
||||
|
||||
async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise<Array<any>> {
|
||||
await ensureWorkbenchSessionTable(c);
|
||||
const rows = await c.db.select().from(taskWorkbenchSessions).orderBy(asc(taskWorkbenchSessions.createdAt)).all();
|
||||
|
|
@ -290,6 +307,24 @@ async function requireReadySessionMeta(c: any, tabId: string): Promise<any> {
|
|||
return meta;
|
||||
}
|
||||
|
||||
async function ensureReadySessionMeta(c: any, tabId: string): Promise<any> {
|
||||
const meta = await readSessionMeta(c, tabId);
|
||||
if (!meta) {
|
||||
throw new Error(`Unknown workbench tab: ${tabId}`);
|
||||
}
|
||||
|
||||
if (meta.status === "ready" && meta.sandboxSessionId) {
|
||||
return meta;
|
||||
}
|
||||
|
||||
if (meta.status === "error") {
|
||||
throw new Error(meta.errorMessage ?? "This workbench tab failed to prepare");
|
||||
}
|
||||
|
||||
await ensureWorkbenchSession(c, tabId);
|
||||
return await requireReadySessionMeta(c, tabId);
|
||||
}
|
||||
|
||||
function shellFragment(parts: string[]): string {
|
||||
return parts.join(" && ");
|
||||
}
|
||||
|
|
@ -662,6 +697,23 @@ async function enqueueWorkbenchRefresh(
|
|||
await self.send(command, body, { wait: false });
|
||||
}
|
||||
|
||||
async function enqueueWorkbenchEnsureSession(c: any, tabId: string): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
"task.command.workbench.ensure_session",
|
||||
{
|
||||
tabId,
|
||||
},
|
||||
{
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function pendingWorkbenchSessionStatus(record: any): "pending_provision" | "pending_session_create" {
|
||||
return record.activeSandboxId ? "pending_session_create" : "pending_provision";
|
||||
}
|
||||
|
||||
async function maybeScheduleWorkbenchRefreshes(c: any, record: any, sessions: Array<any>): Promise<void> {
|
||||
const gitState = await readCachedGitState(c);
|
||||
if (record.activeSandboxId && !gitState.updatedAt) {
|
||||
|
|
@ -721,7 +773,7 @@ export async function ensureWorkbenchSeeded(c: any): Promise<any> {
|
|||
}
|
||||
|
||||
function buildSessionSummary(record: any, meta: any): any {
|
||||
const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null);
|
||||
const derivedSandboxSessionId = meta.status === "ready" ? (meta.sandboxSessionId ?? null) : null;
|
||||
const sessionStatus =
|
||||
meta.status === "pending_provision" || meta.status === "pending_session_create"
|
||||
? meta.status
|
||||
|
|
@ -991,12 +1043,12 @@ export async function createWorkbenchSession(c: any, model?: string): Promise<{
|
|||
await ensureSessionMeta(c, {
|
||||
tabId,
|
||||
model: model ?? defaultModelForAgent(record.agentType),
|
||||
sandboxSessionId: tabId,
|
||||
status: record.activeSandboxId ? "pending_session_create" : "pending_provision",
|
||||
sandboxSessionId: null,
|
||||
status: pendingWorkbenchSessionStatus(record),
|
||||
created: false,
|
||||
});
|
||||
await ensureWorkbenchSession(c, tabId, model);
|
||||
await broadcastTaskUpdate(c, { sessionId: tabId });
|
||||
await enqueueWorkbenchEnsureSession(c, tabId);
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
|
|
@ -1099,14 +1151,60 @@ export async function updateWorkbenchDraft(c: any, sessionId: string, text: stri
|
|||
}
|
||||
|
||||
export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise<void> {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
const meta = await readSessionMeta(c, sessionId);
|
||||
if (!meta || meta.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.model === model) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
let nextMeta = await updateSessionMeta(c, sessionId, {
|
||||
model,
|
||||
});
|
||||
let shouldEnsure = nextMeta.status === "pending_provision" || nextMeta.status === "pending_session_create" || nextMeta.status === "error";
|
||||
|
||||
if (shouldRecreateSessionForModelChange(nextMeta)) {
|
||||
const sandbox = getTaskSandbox(c, c.state.workspaceId, stableSandboxId(c));
|
||||
await sandbox.destroySession(nextMeta.sandboxSessionId);
|
||||
nextMeta = await updateSessionMeta(c, sessionId, {
|
||||
sandboxSessionId: null,
|
||||
status: pendingWorkbenchSessionStatus(record),
|
||||
errorMessage: null,
|
||||
transcriptJson: "[]",
|
||||
transcriptUpdatedAt: null,
|
||||
thinkingSinceMs: null,
|
||||
});
|
||||
shouldEnsure = true;
|
||||
} else if (nextMeta.status === "ready" && nextMeta.sandboxSessionId) {
|
||||
const sandbox = getTaskSandbox(c, c.state.workspaceId, stableSandboxId(c));
|
||||
if (typeof sandbox.rawSendSessionMethod === "function") {
|
||||
try {
|
||||
await sandbox.rawSendSessionMethod(nextMeta.sandboxSessionId, "session/set_config_option", {
|
||||
configId: "model",
|
||||
value: model,
|
||||
});
|
||||
} catch {
|
||||
// Some agents do not allow live model updates. Preserve the new preference in metadata.
|
||||
}
|
||||
}
|
||||
} else if (nextMeta.status !== "ready") {
|
||||
nextMeta = await updateSessionMeta(c, sessionId, {
|
||||
status: pendingWorkbenchSessionStatus(record),
|
||||
errorMessage: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldEnsure) {
|
||||
await enqueueWorkbenchEnsureSession(c, sessionId);
|
||||
}
|
||||
await broadcastTaskUpdate(c, { sessionId });
|
||||
}
|
||||
|
||||
export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
|
||||
const meta = await requireReadySessionMeta(c, sessionId);
|
||||
const meta = await ensureReadySessionMeta(c, sessionId);
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const runtime = await getTaskSandboxRuntime(c, record);
|
||||
await ensureSandboxRepo(c, runtime.sandbox, record);
|
||||
|
|
|
|||
|
|
@ -186,12 +186,16 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
|||
},
|
||||
|
||||
"task.command.workbench.send_message": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-send-message",
|
||||
timeout: 10 * 60_000,
|
||||
run: async () => sendWorkbenchMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
try {
|
||||
await loopCtx.step({
|
||||
name: "workbench-send-message",
|
||||
timeout: 10 * 60_000,
|
||||
run: async () => sendWorkbenchMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
} catch (error) {
|
||||
await msg.complete({ error: resolveErrorMessage(error) });
|
||||
}
|
||||
},
|
||||
|
||||
"task.command.workbench.stop_session": async (loopCtx, msg) => {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import type {
|
|||
TaskWorkbenchSendMessageInput,
|
||||
TaskWorkbenchTabInput,
|
||||
TaskWorkbenchUpdateDraftInput,
|
||||
WorkbenchOpenPrSummary,
|
||||
WorkbenchRepoSummary,
|
||||
WorkbenchSessionSummary,
|
||||
WorkbenchTaskSummary,
|
||||
|
|
@ -36,12 +37,12 @@ import type {
|
|||
WorkspaceUseInput,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
|
||||
import { getGithubData, getOrCreateGithubData, getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { availableSandboxProviderIds, defaultSandboxProviderId } from "../../sandbox-config.js";
|
||||
import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { taskLookup, repos, providerProfiles, taskSummaries } from "./db/schema.js";
|
||||
import { organizationProfile, taskLookup, repos, providerProfiles, taskSummaries } from "./db/schema.js";
|
||||
import { agentTypeForModel } from "../task/workbench.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { workspaceAppActions } from "./app-shell.js";
|
||||
|
|
@ -85,6 +86,8 @@ export function workspaceWorkflowQueueName(name: WorkspaceQueueName): WorkspaceQ
|
|||
return name;
|
||||
}
|
||||
|
||||
const ORGANIZATION_PROFILE_ROW_ID = "profile";
|
||||
|
||||
function assertWorkspace(c: { state: WorkspaceState }, workspaceId: string): void {
|
||||
if (workspaceId !== c.state.workspaceId) {
|
||||
throw new Error(`Workspace actor mismatch: actor=${c.state.workspaceId} command=${workspaceId}`);
|
||||
|
|
@ -203,6 +206,14 @@ function taskSummaryFromRow(row: any): WorkbenchTaskSummary {
|
|||
};
|
||||
}
|
||||
|
||||
async function listOpenPullRequestsSnapshot(c: any, taskRows: WorkbenchTaskSummary[]): Promise<WorkbenchOpenPrSummary[]> {
|
||||
const githubData = getGithubData(c, c.state.workspaceId);
|
||||
const openPullRequests = await githubData.listOpenPullRequests({}).catch(() => []);
|
||||
const claimedBranches = new Set(taskRows.filter((task) => task.branch).map((task) => `${task.repoId}:${task.branch}`));
|
||||
|
||||
return openPullRequests.filter((pullRequest: WorkbenchOpenPrSummary) => !claimedBranches.has(`${pullRequest.repoId}:${pullRequest.headRefName}`));
|
||||
}
|
||||
|
||||
async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySnapshot> {
|
||||
const repoRows = await c.db
|
||||
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })
|
||||
|
|
@ -252,6 +263,7 @@ async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySna
|
|||
workspaceId: c.state.workspaceId,
|
||||
repos: repoRows.map((row) => buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
|
||||
taskSummaries: taskRows,
|
||||
openPullRequests: await listOpenPullRequestsSnapshot(c, taskRows),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -280,8 +292,8 @@ async function waitForWorkbenchTaskReady(task: any, timeoutMs = 5 * 60_000): Pro
|
|||
|
||||
/**
|
||||
* Reads the workspace sidebar snapshot from the workspace actor's local SQLite
|
||||
* only. Task actors push summary updates into `task_summaries`, so clients do
|
||||
* not need this action to fan out to every child actor on the hot read path.
|
||||
* plus the org-scoped GitHub actor for open PRs. Task actors still push
|
||||
* summary updates into `task_summaries`, so the hot read path stays bounded.
|
||||
*/
|
||||
async function getWorkspaceSummarySnapshot(c: any): Promise<WorkspaceSummarySnapshot> {
|
||||
const repoRows = await c.db
|
||||
|
|
@ -300,6 +312,7 @@ async function getWorkspaceSummarySnapshot(c: any): Promise<WorkspaceSummarySnap
|
|||
workspaceId: c.state.workspaceId,
|
||||
repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
|
||||
taskSummaries: summaries,
|
||||
openPullRequests: await listOpenPullRequestsSnapshot(c, summaries),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -463,58 +476,74 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
|
|||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "workspace.command.addRepo") {
|
||||
const result = await loopCtx.step({
|
||||
name: "workspace-add-repo",
|
||||
timeout: 60_000,
|
||||
run: async () => addRepoMutation(loopCtx, msg.body as AddRepoInput),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
try {
|
||||
if (msg.name === "workspace.command.addRepo") {
|
||||
const result = await loopCtx.step({
|
||||
name: "workspace-add-repo",
|
||||
timeout: 60_000,
|
||||
run: async () => addRepoMutation(loopCtx, msg.body as AddRepoInput),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "workspace.command.createTask") {
|
||||
const result = await loopCtx.step({
|
||||
name: "workspace-create-task",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "workspace.command.createTask") {
|
||||
const result = await loopCtx.step({
|
||||
name: "workspace-create-task",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "workspace.command.refreshProviderProfiles") {
|
||||
await loopCtx.step("workspace-refresh-provider-profiles", async () =>
|
||||
refreshProviderProfilesMutation(loopCtx, msg.body as RefreshProviderProfilesCommand),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "workspace.command.refreshProviderProfiles") {
|
||||
await loopCtx.step("workspace-refresh-provider-profiles", async () =>
|
||||
refreshProviderProfilesMutation(loopCtx, msg.body as RefreshProviderProfilesCommand),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "workspace.command.syncGithubSession") {
|
||||
await loopCtx.step({
|
||||
name: "workspace-sync-github-session",
|
||||
timeout: 60_000,
|
||||
run: async () => {
|
||||
const { syncGithubOrganizations } = await import("./app-shell.js");
|
||||
await syncGithubOrganizations(loopCtx, msg.body as { sessionId: string; accessToken: string });
|
||||
},
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "workspace.command.syncGithubSession") {
|
||||
await loopCtx.step({
|
||||
name: "workspace-sync-github-session",
|
||||
timeout: 60_000,
|
||||
run: async () => {
|
||||
const { syncGithubOrganizations } = await import("./app-shell.js");
|
||||
await syncGithubOrganizations(loopCtx, msg.body as { sessionId: string; accessToken: string });
|
||||
},
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "workspace.command.syncGithubOrganizationRepos") {
|
||||
await loopCtx.step({
|
||||
name: "workspace-sync-github-organization-repos",
|
||||
timeout: 60_000,
|
||||
run: async () => {
|
||||
const { syncGithubOrganizationRepos } = await import("./app-shell.js");
|
||||
await syncGithubOrganizationRepos(loopCtx, msg.body as { sessionId: string; organizationId: string });
|
||||
},
|
||||
if (msg.name === "workspace.command.syncGithubOrganizationRepos") {
|
||||
await loopCtx.step({
|
||||
name: "workspace-sync-github-organization-repos",
|
||||
timeout: 60_000,
|
||||
run: async () => {
|
||||
const { syncGithubOrganizationRepos } = await import("./app-shell.js");
|
||||
await syncGithubOrganizationRepos(loopCtx, msg.body as { sessionId: string; organizationId: string });
|
||||
},
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = resolveErrorMessage(error);
|
||||
logActorWarning("workspace", "workspace workflow command failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
queueName: msg.name,
|
||||
error: message,
|
||||
});
|
||||
await msg.complete({ error: message }).catch((completeError: unknown) => {
|
||||
logActorWarning("workspace", "workspace workflow failed completing error response", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
queueName: msg.name,
|
||||
error: resolveErrorMessage(completeError),
|
||||
});
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
return Loop.continue(undefined);
|
||||
|
|
@ -604,6 +633,175 @@ export const workspaceActions = {
|
|||
c.broadcast("workspaceUpdated", { type: "taskRemoved", taskId: input.taskId } satisfies WorkspaceEvent);
|
||||
},
|
||||
|
||||
async findTaskForGithubBranch(c: any, input: { repoId: string; branchName: string }): Promise<{ taskId: string | null }> {
|
||||
const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.repoId)).all();
|
||||
const existing = summaries.find((summary) => summary.branch === input.branchName);
|
||||
return { taskId: existing?.taskId ?? null };
|
||||
},
|
||||
|
||||
async refreshTaskSummaryForGithubBranch(c: any, input: { repoId: string; branchName: string }): Promise<void> {
|
||||
const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.repoId)).all();
|
||||
const matches = summaries.filter((summary) => summary.branch === input.branchName);
|
||||
|
||||
for (const summary of matches) {
|
||||
try {
|
||||
const task = getTask(c, c.state.workspaceId, input.repoId, summary.taskId);
|
||||
await workspaceActions.applyTaskSummaryUpdate(c, {
|
||||
taskSummary: await task.getTaskSummary({}),
|
||||
});
|
||||
} catch (error) {
|
||||
logActorWarning("workspace", "failed refreshing task summary for GitHub branch", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: input.repoId,
|
||||
branchName: input.branchName,
|
||||
taskId: summary.taskId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async applyOpenPullRequestUpdate(c: any, input: { pullRequest: WorkbenchOpenPrSummary }): Promise<void> {
|
||||
const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.pullRequest.repoId)).all();
|
||||
if (summaries.some((summary) => summary.branch === input.pullRequest.headRefName)) {
|
||||
return;
|
||||
}
|
||||
c.broadcast("workspaceUpdated", { type: "pullRequestUpdated", pullRequest: input.pullRequest } satisfies WorkspaceEvent);
|
||||
},
|
||||
|
||||
async removeOpenPullRequest(c: any, input: { prId: string }): Promise<void> {
|
||||
c.broadcast("workspaceUpdated", { type: "pullRequestRemoved", prId: input.prId } satisfies WorkspaceEvent);
|
||||
},
|
||||
|
||||
async applyGithubRepositoryProjection(c: any, input: { repoId: string; remoteUrl: string }): Promise<void> {
|
||||
const now = Date.now();
|
||||
const existing = await c.db.select({ repoId: repos.repoId }).from(repos).where(eq(repos.repoId, input.repoId)).get();
|
||||
await c.db
|
||||
.insert(repos)
|
||||
.values({
|
||||
repoId: input.repoId,
|
||||
remoteUrl: input.remoteUrl,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: repos.repoId,
|
||||
set: {
|
||||
remoteUrl: input.remoteUrl,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
await broadcastRepoSummary(c, existing ? "repoUpdated" : "repoAdded", {
|
||||
repoId: input.repoId,
|
||||
remoteUrl: input.remoteUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
|
||||
async applyGithubDataProjection(
|
||||
c: any,
|
||||
input: {
|
||||
connectedAccount: string;
|
||||
installationStatus: string;
|
||||
installationId: number | null;
|
||||
syncStatus: string;
|
||||
lastSyncLabel: string;
|
||||
lastSyncAt: number | null;
|
||||
repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const existingRepos = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt }).from(repos).all();
|
||||
const existingById = new Map(existingRepos.map((repo) => [repo.repoId, repo]));
|
||||
const nextRepoIds = new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
for (const repository of input.repositories) {
|
||||
const repoId = repoIdFromRemote(repository.cloneUrl);
|
||||
nextRepoIds.add(repoId);
|
||||
await c.db
|
||||
.insert(repos)
|
||||
.values({
|
||||
repoId,
|
||||
remoteUrl: repository.cloneUrl,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: repos.repoId,
|
||||
set: {
|
||||
remoteUrl: repository.cloneUrl,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
await broadcastRepoSummary(c, existingById.has(repoId) ? "repoUpdated" : "repoAdded", {
|
||||
repoId,
|
||||
remoteUrl: repository.cloneUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
for (const repo of existingRepos) {
|
||||
if (nextRepoIds.has(repo.repoId)) {
|
||||
continue;
|
||||
}
|
||||
await c.db.delete(repos).where(eq(repos.repoId, repo.repoId)).run();
|
||||
c.broadcast("workspaceUpdated", { type: "repoRemoved", repoId: repo.repoId } satisfies WorkspaceEvent);
|
||||
}
|
||||
|
||||
const profile = await c.db
|
||||
.select({ id: organizationProfile.id })
|
||||
.from(organizationProfile)
|
||||
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
|
||||
.get();
|
||||
if (profile) {
|
||||
await c.db
|
||||
.update(organizationProfile)
|
||||
.set({
|
||||
githubConnectedAccount: input.connectedAccount,
|
||||
githubInstallationStatus: input.installationStatus,
|
||||
githubSyncStatus: input.syncStatus,
|
||||
githubInstallationId: input.installationId,
|
||||
githubLastSyncLabel: input.lastSyncLabel,
|
||||
githubLastSyncAt: input.lastSyncAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
|
||||
.run();
|
||||
}
|
||||
},
|
||||
|
||||
async recordGithubWebhookReceipt(
|
||||
c: any,
|
||||
input: {
|
||||
workspaceId: string;
|
||||
event: string;
|
||||
action?: string | null;
|
||||
receivedAt?: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
|
||||
const profile = await c.db
|
||||
.select({ id: organizationProfile.id })
|
||||
.from(organizationProfile)
|
||||
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
|
||||
.get();
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
await c.db
|
||||
.update(organizationProfile)
|
||||
.set({
|
||||
githubLastWebhookAt: input.receivedAt ?? Date.now(),
|
||||
githubLastWebhookEvent: input.action ? `${input.event}.${input.action}` : input.event,
|
||||
})
|
||||
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
|
||||
.run();
|
||||
},
|
||||
|
||||
async getWorkspaceSummary(c: any, input: WorkspaceUseInput): Promise<WorkspaceSummarySnapshot> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
return await getWorkspaceSummarySnapshot(c);
|
||||
|
|
@ -620,7 +818,7 @@ export const workspaceActions = {
|
|||
repoId: input.repoId,
|
||||
task: input.task,
|
||||
...(input.title ? { explicitTitle: input.title } : {}),
|
||||
...(input.branch ? { explicitBranchName: input.branch } : {}),
|
||||
...(input.onBranch ? { onBranch: input.onBranch } : input.branch ? { explicitBranchName: input.branch } : {}),
|
||||
...(input.model ? { agentType: agentTypeForModel(input.model) } : {}),
|
||||
});
|
||||
const task = await requireWorkbenchTask(c, created.taskId);
|
||||
|
|
@ -634,6 +832,10 @@ export const workspaceActions = {
|
|||
tabId: session.tabId,
|
||||
text: input.task,
|
||||
attachments: [],
|
||||
waitForCompletion: true,
|
||||
});
|
||||
await task.getSessionDetail({
|
||||
sessionId: session.tabId,
|
||||
});
|
||||
return {
|
||||
taskId: created.taskId,
|
||||
|
|
@ -706,6 +908,22 @@ export const workspaceActions = {
|
|||
await task.revertWorkbenchFile(input);
|
||||
},
|
||||
|
||||
async reloadGithubOrganization(c: any): Promise<void> {
|
||||
await getOrCreateGithubData(c, c.state.workspaceId).reloadOrganization({});
|
||||
},
|
||||
|
||||
async reloadGithubPullRequests(c: any): Promise<void> {
|
||||
await getOrCreateGithubData(c, c.state.workspaceId).reloadAllPullRequests({});
|
||||
},
|
||||
|
||||
async reloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
|
||||
await getOrCreateGithubData(c, c.state.workspaceId).reloadRepository(input);
|
||||
},
|
||||
|
||||
async reloadGithubPullRequest(c: any, input: { repoId: string; prNumber: number }): Promise<void> {
|
||||
await getOrCreateGithubData(c, c.state.workspaceId).reloadPullRequest(input);
|
||||
},
|
||||
|
||||
async listTasks(c: any, input: ListTasksInput): Promise<TaskSummary[]> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import type {
|
|||
UpdateFoundryOrganizationProfileInput,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getOrCreateWorkspace, selfWorkspace } from "../handles.js";
|
||||
import { getOrCreateGithubData, getOrCreateWorkspace, selfWorkspace } from "../handles.js";
|
||||
import { GitHubAppError } from "../../services/app-github.js";
|
||||
import { getBetterAuthService } from "../../services/better-auth.js";
|
||||
import { repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js";
|
||||
|
|
@ -601,40 +601,19 @@ export async function syncGithubOrganizationRepos(c: any, input: { sessionId: st
|
|||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
requireEligibleOrganization(session, input.organizationId);
|
||||
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const workspace = await getOrCreateWorkspace(c, input.organizationId);
|
||||
const organization = await getOrganizationState(workspace);
|
||||
const githubData = await getOrCreateGithubData(c, input.organizationId);
|
||||
|
||||
try {
|
||||
let repositories;
|
||||
let installationStatus = organization.snapshot.github.installationStatus;
|
||||
|
||||
if (organization.snapshot.kind === "personal") {
|
||||
repositories = await appShell.github.listUserRepositories(session.githubAccessToken);
|
||||
installationStatus = "connected";
|
||||
} else if (organization.githubInstallationId) {
|
||||
try {
|
||||
repositories = await appShell.github.listInstallationRepositories(organization.githubInstallationId);
|
||||
} catch (error) {
|
||||
if (!(error instanceof GitHubAppError) || (error.status !== 403 && error.status !== 404)) {
|
||||
throw error;
|
||||
}
|
||||
repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) =>
|
||||
repository.fullName.startsWith(`${organization.githubLogin}/`),
|
||||
);
|
||||
installationStatus = "reconnect_required";
|
||||
}
|
||||
} else {
|
||||
repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) =>
|
||||
repository.fullName.startsWith(`${organization.githubLogin}/`),
|
||||
);
|
||||
installationStatus = "reconnect_required";
|
||||
}
|
||||
|
||||
await workspace.applyOrganizationSyncCompleted({
|
||||
repositories,
|
||||
installationStatus,
|
||||
lastSyncLabel: repositories.length > 0 ? "Synced just now" : "No repositories available",
|
||||
await githubData.fullSync({
|
||||
accessToken: session.githubAccessToken,
|
||||
connectedAccount: organization.snapshot.github.connectedAccount,
|
||||
installationId: organization.githubInstallationId,
|
||||
installationStatus: organization.snapshot.github.installationStatus,
|
||||
githubLogin: organization.githubLogin,
|
||||
kind: organization.snapshot.kind,
|
||||
label: "Importing repository catalog...",
|
||||
});
|
||||
|
||||
// Broadcast updated app snapshot so connected clients see the new repos
|
||||
|
|
@ -759,6 +738,8 @@ async function buildOrganizationStateFromRow(c: any, row: any, startedAt: number
|
|||
importedRepoCount: repoCatalog.length,
|
||||
lastSyncLabel: row.githubLastSyncLabel,
|
||||
lastSyncAt: row.githubLastSyncAt ?? null,
|
||||
lastWebhookAt: row.githubLastWebhookAt ?? null,
|
||||
lastWebhookEvent: row.githubLastWebhookEvent ?? "",
|
||||
},
|
||||
billing: {
|
||||
planId: row.billingPlanId,
|
||||
|
|
@ -1433,8 +1414,8 @@ export const workspaceAppActions = {
|
|||
const { appShell } = getActorRuntimeContext();
|
||||
const { event, body } = appShell.github.verifyWebhookEvent(input.payload, input.signatureHeader, input.eventHeader);
|
||||
|
||||
const accountLogin = body.installation?.account?.login;
|
||||
const accountType = body.installation?.account?.type;
|
||||
const accountLogin = body.installation?.account?.login ?? body.repository?.owner?.login ?? body.organization?.login ?? null;
|
||||
const accountType = body.installation?.account?.type ?? (body.organization?.login ? "Organization" : null);
|
||||
if (!accountLogin) {
|
||||
githubWebhookLogger.info(
|
||||
{
|
||||
|
|
@ -1449,6 +1430,15 @@ export const workspaceAppActions = {
|
|||
|
||||
const kind: FoundryOrganization["kind"] = accountType === "User" ? "personal" : "organization";
|
||||
const organizationId = organizationWorkspaceId(kind, accountLogin);
|
||||
const receivedAt = Date.now();
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
await workspace.recordGithubWebhookReceipt({
|
||||
workspaceId: organizationId,
|
||||
event,
|
||||
action: body.action ?? null,
|
||||
receivedAt,
|
||||
});
|
||||
const githubData = await getOrCreateGithubData(c, organizationId);
|
||||
|
||||
if (event === "installation" && (body.action === "created" || body.action === "deleted" || body.action === "suspend" || body.action === "unsuspend")) {
|
||||
githubWebhookLogger.info(
|
||||
|
|
@ -1461,12 +1451,36 @@ export const workspaceAppActions = {
|
|||
"installation_event",
|
||||
);
|
||||
if (body.action === "deleted") {
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
await workspace.applyGithubInstallationRemoved({});
|
||||
await githubData.clearState({
|
||||
connectedAccount: accountLogin,
|
||||
installationStatus: "install_required",
|
||||
installationId: null,
|
||||
label: "GitHub App installation removed",
|
||||
});
|
||||
} else if (body.action === "created") {
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
await workspace.applyGithubInstallationCreated({
|
||||
installationId: body.installation?.id ?? 0,
|
||||
await githubData.fullSync({
|
||||
connectedAccount: accountLogin,
|
||||
installationStatus: "connected",
|
||||
installationId: body.installation?.id ?? null,
|
||||
githubLogin: accountLogin,
|
||||
kind,
|
||||
label: "Syncing GitHub data from installation webhook...",
|
||||
});
|
||||
} else if (body.action === "suspend") {
|
||||
await githubData.clearState({
|
||||
connectedAccount: accountLogin,
|
||||
installationStatus: "reconnect_required",
|
||||
installationId: body.installation?.id ?? null,
|
||||
label: "GitHub App installation suspended",
|
||||
});
|
||||
} else if (body.action === "unsuspend") {
|
||||
await githubData.fullSync({
|
||||
connectedAccount: accountLogin,
|
||||
installationStatus: "connected",
|
||||
installationId: body.installation?.id ?? null,
|
||||
githubLogin: accountLogin,
|
||||
kind,
|
||||
label: "Resyncing GitHub data after unsuspend...",
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
|
|
@ -1484,13 +1498,13 @@ export const workspaceAppActions = {
|
|||
},
|
||||
"repository_membership_changed",
|
||||
);
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
await workspace.applyGithubRepositoryChanges({
|
||||
added: (body.repositories_added ?? []).map((r) => ({
|
||||
fullName: r.full_name,
|
||||
private: r.private,
|
||||
})),
|
||||
removed: (body.repositories_removed ?? []).map((r) => r.full_name),
|
||||
await githubData.fullSync({
|
||||
connectedAccount: accountLogin,
|
||||
installationStatus: "connected",
|
||||
installationId: body.installation?.id ?? null,
|
||||
githubLogin: accountLogin,
|
||||
kind,
|
||||
label: "Resyncing GitHub data after repository access change...",
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -1518,7 +1532,30 @@ export const workspaceAppActions = {
|
|||
},
|
||||
"repository_event",
|
||||
);
|
||||
// TODO: Dispatch to GitHubStateActor / downstream actors
|
||||
if (event === "pull_request" && body.repository?.clone_url && body.pull_request) {
|
||||
await githubData.handlePullRequestWebhook({
|
||||
connectedAccount: accountLogin,
|
||||
installationStatus: "connected",
|
||||
installationId: body.installation?.id ?? null,
|
||||
repository: {
|
||||
fullName: body.repository.full_name,
|
||||
cloneUrl: body.repository.clone_url,
|
||||
private: Boolean(body.repository.private),
|
||||
},
|
||||
pullRequest: {
|
||||
number: body.pull_request.number,
|
||||
title: body.pull_request.title ?? "",
|
||||
body: body.pull_request.body ?? null,
|
||||
state: body.pull_request.state ?? "open",
|
||||
url: body.pull_request.html_url ?? `https://github.com/${body.repository.full_name}/pull/${body.pull_request.number}`,
|
||||
headRefName: body.pull_request.head?.ref ?? "",
|
||||
baseRefName: body.pull_request.base?.ref ?? "",
|
||||
authorLogin: body.pull_request.user?.login ?? null,
|
||||
isDraft: Boolean(body.pull_request.draft),
|
||||
merged: Boolean(body.pull_request.merged),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ CREATE TABLE `organization_profile` (
|
|||
`github_installation_id` integer,
|
||||
`github_last_sync_label` text NOT NULL,
|
||||
`github_last_sync_at` integer,
|
||||
`github_last_webhook_at` integer,
|
||||
`github_last_webhook_event` text,
|
||||
`stripe_customer_id` text,
|
||||
`stripe_subscription_id` text,
|
||||
`stripe_price_id` text,
|
||||
|
|
|
|||
|
|
@ -359,6 +359,20 @@
|
|||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_last_webhook_at": {
|
||||
"name": "github_last_webhook_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_last_webhook_event": {
|
||||
"name": "github_last_webhook_event",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripe_customer_id": {
|
||||
"name": "stripe_customer_id",
|
||||
"type": "text",
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ CREATE TABLE \`organization_profile\` (
|
|||
\`github_installation_id\` integer,
|
||||
\`github_last_sync_label\` text NOT NULL,
|
||||
\`github_last_sync_at\` integer,
|
||||
\`github_last_webhook_at\` integer,
|
||||
\`github_last_webhook_event\` text,
|
||||
\`stripe_customer_id\` text,
|
||||
\`stripe_subscription_id\` text,
|
||||
\`stripe_price_id\` text,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ export const organizationProfile = sqliteTable("organization_profile", {
|
|||
githubInstallationId: integer("github_installation_id"),
|
||||
githubLastSyncLabel: text("github_last_sync_label").notNull(),
|
||||
githubLastSyncAt: integer("github_last_sync_at"),
|
||||
githubLastWebhookAt: integer("github_last_webhook_at"),
|
||||
githubLastWebhookEvent: text("github_last_webhook_event"),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||
stripePriceId: text("stripe_price_id"),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,30 @@ export interface GitHubRepositoryRecord {
|
|||
private: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubMemberRecord {
|
||||
id: string;
|
||||
login: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
role: string | null;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface GitHubPullRequestRecord {
|
||||
repoFullName: string;
|
||||
cloneUrl: string;
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
authorLogin: string | null;
|
||||
isDraft: boolean;
|
||||
merged: boolean;
|
||||
}
|
||||
|
||||
interface GitHubTokenResponse {
|
||||
access_token?: string;
|
||||
scope?: string;
|
||||
|
|
@ -58,11 +82,23 @@ const githubOAuthLogger = logger.child({
|
|||
|
||||
export interface GitHubWebhookEvent {
|
||||
action?: string;
|
||||
organization?: { login?: string; id?: number };
|
||||
installation?: { id: number; account?: { login?: string; type?: string; id?: number } | null };
|
||||
repositories_added?: Array<{ id: number; full_name: string; private: boolean }>;
|
||||
repositories_removed?: Array<{ id: number; full_name: string }>;
|
||||
repository?: { id: number; full_name: string; clone_url?: string; private?: boolean; owner?: { login?: string } };
|
||||
pull_request?: { number: number; title?: string; state?: string; head?: { ref?: string }; base?: { ref?: string } };
|
||||
pull_request?: {
|
||||
number: number;
|
||||
title?: string;
|
||||
body?: string | null;
|
||||
state?: string;
|
||||
html_url?: string;
|
||||
draft?: boolean;
|
||||
merged?: boolean;
|
||||
user?: { login?: string } | null;
|
||||
head?: { ref?: string };
|
||||
base?: { ref?: string };
|
||||
};
|
||||
sender?: { login?: string; id?: number };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
@ -329,6 +365,130 @@ export class GitHubAppClient {
|
|||
}));
|
||||
}
|
||||
|
||||
async getUserRepository(accessToken: string, fullName: string): Promise<GitHubRepositoryRecord | null> {
|
||||
try {
|
||||
const repository = await this.requestJson<{
|
||||
full_name: string;
|
||||
clone_url: string;
|
||||
private: boolean;
|
||||
}>(`/repos/${fullName}`, accessToken);
|
||||
return {
|
||||
fullName: repository.full_name,
|
||||
cloneUrl: repository.clone_url,
|
||||
private: repository.private,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof GitHubAppError && error.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getInstallationRepository(installationId: number, fullName: string): Promise<GitHubRepositoryRecord | null> {
|
||||
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||
return await this.getUserRepository(accessToken, fullName);
|
||||
}
|
||||
|
||||
async listOrganizationMembers(accessToken: string, organizationLogin: string): Promise<GitHubMemberRecord[]> {
|
||||
const members = await this.paginate<{
|
||||
id: number;
|
||||
login: string;
|
||||
role?: string | null;
|
||||
}>(`/orgs/${organizationLogin}/members?per_page=100&role=all`, accessToken);
|
||||
|
||||
const detailedMembers = await Promise.all(
|
||||
members.map(async (member) => {
|
||||
try {
|
||||
const detail = await this.requestJson<{
|
||||
id: number;
|
||||
login: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
}>(`/users/${member.login}`, accessToken);
|
||||
return {
|
||||
id: String(detail.id),
|
||||
login: detail.login,
|
||||
name: detail.name?.trim() || detail.login,
|
||||
email: detail.email ?? null,
|
||||
role: member.role ?? null,
|
||||
state: "active",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: String(member.id),
|
||||
login: member.login,
|
||||
name: member.login,
|
||||
email: null,
|
||||
role: member.role ?? null,
|
||||
state: "active",
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return detailedMembers;
|
||||
}
|
||||
|
||||
async listInstallationMembers(installationId: number, organizationLogin: string): Promise<GitHubMemberRecord[]> {
|
||||
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||
return await this.listOrganizationMembers(accessToken, organizationLogin);
|
||||
}
|
||||
|
||||
async listPullRequestsForUserRepositories(accessToken: string, repositories: GitHubRepositoryRecord[]): Promise<GitHubPullRequestRecord[]> {
|
||||
return (await Promise.all(repositories.map((repository) => this.listRepositoryPullRequests(accessToken, repository.fullName, repository.cloneUrl)))).flat();
|
||||
}
|
||||
|
||||
async listInstallationPullRequestsForRepositories(installationId: number, repositories: GitHubRepositoryRecord[]): Promise<GitHubPullRequestRecord[]> {
|
||||
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||
return await this.listPullRequestsForUserRepositories(accessToken, repositories);
|
||||
}
|
||||
|
||||
async getUserPullRequest(accessToken: string, fullName: string, prNumber: number): Promise<GitHubPullRequestRecord | null> {
|
||||
try {
|
||||
const pullRequest = await this.requestJson<{
|
||||
number: number;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
state: string;
|
||||
html_url: string;
|
||||
draft?: boolean;
|
||||
merged?: boolean;
|
||||
user?: { login?: string } | null;
|
||||
head?: { ref?: string } | null;
|
||||
base?: { ref?: string } | null;
|
||||
}>(`/repos/${fullName}/pulls/${prNumber}`, accessToken);
|
||||
const repository = await this.getUserRepository(accessToken, fullName);
|
||||
if (!repository) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
repoFullName: fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
number: pullRequest.number,
|
||||
title: pullRequest.title,
|
||||
body: pullRequest.body ?? null,
|
||||
state: pullRequest.state,
|
||||
url: pullRequest.html_url,
|
||||
headRefName: pullRequest.head?.ref?.trim() ?? "",
|
||||
baseRefName: pullRequest.base?.ref?.trim() ?? "",
|
||||
authorLogin: pullRequest.user?.login?.trim() ?? null,
|
||||
isDraft: Boolean(pullRequest.draft),
|
||||
merged: Boolean(pullRequest.merged),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof GitHubAppError && error.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getInstallationPullRequest(installationId: number, fullName: string, prNumber: number): Promise<GitHubPullRequestRecord | null> {
|
||||
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||
return await this.getUserPullRequest(accessToken, fullName, prNumber);
|
||||
}
|
||||
|
||||
async buildInstallationUrl(organizationLogin: string, state: string): Promise<string> {
|
||||
if (!this.isAppConfigured()) {
|
||||
throw new GitHubAppError("GitHub App is not configured", 500);
|
||||
|
|
@ -437,6 +597,36 @@ export class GitHubAppClient {
|
|||
return payload as T;
|
||||
}
|
||||
|
||||
private async listRepositoryPullRequests(accessToken: string, fullName: string, cloneUrl: string): Promise<GitHubPullRequestRecord[]> {
|
||||
const pullRequests = await this.paginate<{
|
||||
number: number;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
state: string;
|
||||
html_url: string;
|
||||
draft?: boolean;
|
||||
merged?: boolean;
|
||||
user?: { login?: string } | null;
|
||||
head?: { ref?: string } | null;
|
||||
base?: { ref?: string } | null;
|
||||
}>(`/repos/${fullName}/pulls?state=open&per_page=100&sort=updated&direction=desc`, accessToken);
|
||||
|
||||
return pullRequests.map((pullRequest) => ({
|
||||
repoFullName: fullName,
|
||||
cloneUrl,
|
||||
number: pullRequest.number,
|
||||
title: pullRequest.title,
|
||||
body: pullRequest.body ?? null,
|
||||
state: pullRequest.state,
|
||||
url: pullRequest.html_url,
|
||||
headRefName: pullRequest.head?.ref?.trim() ?? "",
|
||||
baseRefName: pullRequest.base?.ref?.trim() ?? "",
|
||||
authorLogin: pullRequest.user?.login?.trim() ?? null,
|
||||
isDraft: Boolean(pullRequest.draft),
|
||||
merged: Boolean(pullRequest.merged),
|
||||
}));
|
||||
}
|
||||
|
||||
private async paginate<T>(path: string, accessToken: string): Promise<T[]> {
|
||||
let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
const items: T[] = [];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ export function expectQueueResponse<T>(result: QueueSendResult | void): T {
|
|||
if (!result || result.status === "timedOut") {
|
||||
throw new Error("Queue command timed out");
|
||||
}
|
||||
if (
|
||||
result.response &&
|
||||
typeof result.response === "object" &&
|
||||
"error" in result.response &&
|
||||
typeof (result.response as { error?: unknown }).error === "string"
|
||||
) {
|
||||
throw new Error((result.response as { error: string }).error);
|
||||
}
|
||||
return result.response as T;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { taskKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, taskSandboxKey, workspaceKey } from "../src/actors/keys.js";
|
||||
import { githubDataKey, historyKey, projectBranchSyncKey, projectKey, taskKey, taskSandboxKey, workspaceKey } from "../src/actors/keys.js";
|
||||
|
||||
describe("actor keys", () => {
|
||||
it("prefixes every key with workspace namespace", () => {
|
||||
|
|
@ -9,7 +9,7 @@ describe("actor keys", () => {
|
|||
taskKey("default", "repo", "task"),
|
||||
taskSandboxKey("default", "sbx"),
|
||||
historyKey("default", "repo"),
|
||||
projectPrSyncKey("default", "repo"),
|
||||
githubDataKey("default"),
|
||||
projectBranchSyncKey("default", "repo"),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { shouldMarkSessionUnreadForStatus } from "../src/actors/task/workbench.js";
|
||||
import { shouldMarkSessionUnreadForStatus, shouldRecreateSessionForModelChange } from "../src/actors/task/workbench.js";
|
||||
|
||||
describe("workbench unread status transitions", () => {
|
||||
it("marks unread when a running session first becomes idle", () => {
|
||||
|
|
@ -14,3 +14,46 @@ describe("workbench unread status transitions", () => {
|
|||
expect(shouldMarkSessionUnreadForStatus({ thinkingSinceMs: Date.now() - 1_000 }, "running")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workbench model changes", () => {
|
||||
it("recreates an unused ready session so the selected model takes effect", () => {
|
||||
expect(
|
||||
shouldRecreateSessionForModelChange({
|
||||
status: "ready",
|
||||
sandboxSessionId: "session-1",
|
||||
created: false,
|
||||
transcript: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not recreate a session once the conversation has started", () => {
|
||||
expect(
|
||||
shouldRecreateSessionForModelChange({
|
||||
status: "ready",
|
||||
sandboxSessionId: "session-1",
|
||||
created: true,
|
||||
transcript: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not recreate pending or anonymous sessions", () => {
|
||||
expect(
|
||||
shouldRecreateSessionForModelChange({
|
||||
status: "pending_session_create",
|
||||
sandboxSessionId: "session-1",
|
||||
created: false,
|
||||
transcript: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRecreateSessionForModelChange({
|
||||
status: "ready",
|
||||
sandboxSessionId: null,
|
||||
created: false,
|
||||
transcript: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -112,6 +112,10 @@ interface WorkspaceHandle {
|
|||
closeWorkbenchSession(input: TaskWorkbenchTabInput): Promise<void>;
|
||||
publishWorkbenchPr(input: TaskWorkbenchSelectInput): Promise<void>;
|
||||
revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>;
|
||||
reloadGithubOrganization(): Promise<void>;
|
||||
reloadGithubPullRequests(): Promise<void>;
|
||||
reloadGithubRepository(input: { repoId: string }): Promise<void>;
|
||||
reloadGithubPullRequest(input: { repoId: string; prNumber: number }): Promise<void>;
|
||||
}
|
||||
|
||||
interface AppWorkspaceHandle {
|
||||
|
|
@ -296,6 +300,10 @@ export interface BackendClient {
|
|||
closeWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise<void>;
|
||||
publishWorkbenchPr(workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void>;
|
||||
revertWorkbenchFile(workspaceId: string, input: TaskWorkbenchDiffInput): Promise<void>;
|
||||
reloadGithubOrganization(workspaceId: string): Promise<void>;
|
||||
reloadGithubPullRequests(workspaceId: string): Promise<void>;
|
||||
reloadGithubRepository(workspaceId: string, repoId: string): Promise<void>;
|
||||
reloadGithubPullRequest(workspaceId: string, repoId: string, prNumber: number): Promise<void>;
|
||||
health(): Promise<{ ok: true }>;
|
||||
useWorkspace(workspaceId: string): Promise<{ workspaceId: string }>;
|
||||
starSandboxAgentRepo(workspaceId: string): Promise<StarSandboxAgentRepoResult>;
|
||||
|
|
@ -1182,6 +1190,22 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
await (await workspace(workspaceId)).revertWorkbenchFile(input);
|
||||
},
|
||||
|
||||
async reloadGithubOrganization(workspaceId: string): Promise<void> {
|
||||
await (await workspace(workspaceId)).reloadGithubOrganization();
|
||||
},
|
||||
|
||||
async reloadGithubPullRequests(workspaceId: string): Promise<void> {
|
||||
await (await workspace(workspaceId)).reloadGithubPullRequests();
|
||||
},
|
||||
|
||||
async reloadGithubRepository(workspaceId: string, repoId: string): Promise<void> {
|
||||
await (await workspace(workspaceId)).reloadGithubRepository({ repoId });
|
||||
},
|
||||
|
||||
async reloadGithubPullRequest(workspaceId: string, repoId: string, prNumber: number): Promise<void> {
|
||||
await (await workspace(workspaceId)).reloadGithubPullRequest({ repoId, prNumber });
|
||||
},
|
||||
|
||||
async health(): Promise<{ ok: true }> {
|
||||
const workspaceId = options.defaultWorkspaceId;
|
||||
if (!workspaceId) {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ function upsertById<T extends { id: string }>(items: T[], nextItem: T, sort: (le
|
|||
return [...filtered, nextItem].sort(sort);
|
||||
}
|
||||
|
||||
function upsertByPrId<T extends { prId: string }>(items: T[], nextItem: T, sort: (left: T, right: T) => number): T[] {
|
||||
const filtered = items.filter((item) => item.prId !== nextItem.prId);
|
||||
return [...filtered, nextItem].sort(sort);
|
||||
}
|
||||
|
||||
export const topicDefinitions = {
|
||||
app: {
|
||||
key: () => "app",
|
||||
|
|
@ -90,6 +95,16 @@ export const topicDefinitions = {
|
|||
...current,
|
||||
repos: current.repos.filter((repo) => repo.id !== event.repoId),
|
||||
};
|
||||
case "pullRequestUpdated":
|
||||
return {
|
||||
...current,
|
||||
openPullRequests: upsertByPrId(current.openPullRequests, event.pullRequest, (left, right) => right.updatedAtMs - left.updatedAtMs),
|
||||
};
|
||||
case "pullRequestRemoved":
|
||||
return {
|
||||
...current,
|
||||
openPullRequests: current.openPullRequests.filter((pullRequest) => pullRequest.prId !== event.prId),
|
||||
};
|
||||
}
|
||||
},
|
||||
} satisfies TopicDefinition<WorkspaceSummarySnapshot, WorkspaceTopicParams, WorkspaceEvent>,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ export interface MockFoundryGithubState {
|
|||
importedRepoCount: number;
|
||||
lastSyncLabel: string;
|
||||
lastSyncAt: number | null;
|
||||
lastWebhookAt: number | null;
|
||||
lastWebhookEvent: string;
|
||||
}
|
||||
|
||||
export interface MockFoundryOrganizationSettings {
|
||||
|
|
@ -188,6 +190,8 @@ function buildRivetOrganization(): MockFoundryOrganization {
|
|||
importedRepoCount: repos.length,
|
||||
lastSyncLabel: "Synced just now",
|
||||
lastSyncAt: Date.now() - 60_000,
|
||||
lastWebhookAt: Date.now() - 30_000,
|
||||
lastWebhookEvent: "push",
|
||||
},
|
||||
billing: {
|
||||
planId: "team",
|
||||
|
|
@ -267,6 +271,8 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
|||
importedRepoCount: 1,
|
||||
lastSyncLabel: "Synced just now",
|
||||
lastSyncAt: Date.now() - 60_000,
|
||||
lastWebhookAt: Date.now() - 120_000,
|
||||
lastWebhookEvent: "pull_request.opened",
|
||||
},
|
||||
billing: {
|
||||
planId: "free",
|
||||
|
|
@ -301,6 +307,8 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
|||
importedRepoCount: 3,
|
||||
lastSyncLabel: "Waiting for first import",
|
||||
lastSyncAt: null,
|
||||
lastWebhookAt: null,
|
||||
lastWebhookEvent: "",
|
||||
},
|
||||
billing: {
|
||||
planId: "team",
|
||||
|
|
@ -344,6 +352,8 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
|||
importedRepoCount: 1,
|
||||
lastSyncLabel: "Synced yesterday",
|
||||
lastSyncAt: Date.now() - 24 * 60 * 60_000,
|
||||
lastWebhookAt: Date.now() - 3_600_000,
|
||||
lastWebhookEvent: "check_run.completed",
|
||||
},
|
||||
billing: {
|
||||
planId: "free",
|
||||
|
|
@ -397,6 +407,8 @@ function parseStoredSnapshot(): MockFoundryAppSnapshot | null {
|
|||
...organization.github,
|
||||
syncStatus: syncStatusFromLegacy(organization.github?.syncStatus ?? organization.repoImportStatus),
|
||||
lastSyncAt: organization.github?.lastSyncAt ?? null,
|
||||
lastWebhookAt: organization.github?.lastWebhookAt ?? null,
|
||||
lastWebhookEvent: organization.github?.lastWebhookEvent ?? "",
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
|
@ -567,6 +579,8 @@ class MockFoundryAppStore implements MockFoundryAppClient {
|
|||
syncStatus: "synced",
|
||||
lastSyncLabel: "Synced just now",
|
||||
lastSyncAt: Date.now(),
|
||||
lastWebhookAt: Date.now(),
|
||||
lastWebhookEvent: "installation_repositories.added",
|
||||
},
|
||||
}));
|
||||
this.importTimers.delete(organizationId);
|
||||
|
|
|
|||
|
|
@ -249,6 +249,7 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
|||
};
|
||||
}),
|
||||
taskSummaries,
|
||||
openPullRequests: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -763,6 +764,14 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
|||
emitTaskUpdate(input.taskId);
|
||||
},
|
||||
|
||||
async reloadGithubOrganization(): Promise<void> {},
|
||||
|
||||
async reloadGithubPullRequests(): Promise<void> {},
|
||||
|
||||
async reloadGithubRepository(): Promise<void> {},
|
||||
|
||||
async reloadGithubPullRequest(): Promise<void> {},
|
||||
|
||||
async health(): Promise<{ ok: true }> {
|
||||
return { ok: true };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -100,7 +100,8 @@ class RemoteWorkbenchStore implements TaskWorkbenchClient {
|
|||
|
||||
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
|
||||
await this.backend.updateWorkbenchDraft(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
// Skip refresh — the server broadcast will trigger it, and the frontend
|
||||
// holds local draft state to avoid the round-trip overwriting user input.
|
||||
}
|
||||
|
||||
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ function workspaceSnapshot(): WorkspaceSummarySnapshot {
|
|||
sessionsSummary: [],
|
||||
},
|
||||
],
|
||||
openPullRequests: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,10 +71,10 @@ function timeAgo(ts: number | null): string {
|
|||
if (!ts) return "never";
|
||||
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||||
if (seconds < 5) return "now";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
return `${Math.floor(minutes / 60)}h`;
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
return `${Math.floor(minutes / 60)}h ago`;
|
||||
}
|
||||
|
||||
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||
|
|
@ -157,8 +157,11 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
}, [now]);
|
||||
|
||||
const repos = snapshot.repos ?? [];
|
||||
const prCount = (snapshot.tasks ?? []).filter((task) => task.pullRequest != null).length;
|
||||
const focusedTaskStatus = focusedTask?.runtimeStatus ?? focusedTask?.status ?? null;
|
||||
const focusedTaskState = describeTaskState(focusedTaskStatus, focusedTask?.statusMessage ?? null);
|
||||
const lastWebhookAt = organization?.github.lastWebhookAt ?? null;
|
||||
const hasRecentWebhook = lastWebhookAt != null && now - lastWebhookAt < 5 * 60_000;
|
||||
|
||||
const mono = css({
|
||||
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace",
|
||||
|
|
@ -436,8 +439,28 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
|||
<span className={css({ color: t.textPrimary, flex: 1 })}>Sync</span>
|
||||
<span className={`${mono} ${css({ color: syncStatusColor(organization.github.syncStatus, t) })}`}>{organization.github.syncStatus}</span>
|
||||
</div>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: hasRecentWebhook ? t.statusSuccess : t.textMuted,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ color: t.textPrimary, flex: 1 })}>Webhook</span>
|
||||
{lastWebhookAt != null ? (
|
||||
<span className={`${mono} ${css({ color: hasRecentWebhook ? t.textPrimary : t.textMuted })}`}>
|
||||
{organization.github.lastWebhookEvent} · {timeAgo(lastWebhookAt)}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>never received</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
|
||||
<Stat label="repos imported" value={organization.github.importedRepoCount} t={t} css={css} />
|
||||
<Stat label="repos" value={organization.github.importedRepoCount} t={t} css={css} />
|
||||
<Stat label="PRs" value={prCount} t={t} css={css} />
|
||||
</div>
|
||||
{organization.github.connectedAccount && (
|
||||
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{organization.github.connectedAccount}</div>
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ import { useNavigate } from "@tanstack/react-router";
|
|||
import { useStyletron } from "baseui";
|
||||
import {
|
||||
createErrorContext,
|
||||
type FoundryOrganization,
|
||||
type TaskWorkbenchSnapshot,
|
||||
type WorkbenchOpenPrSummary,
|
||||
type WorkbenchSessionSummary,
|
||||
type WorkbenchTaskDetail,
|
||||
type WorkbenchTaskSummary,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||
|
||||
import { PanelLeft, PanelRight } from "lucide-react";
|
||||
import { CircleAlert, PanelLeft, PanelRight } from "lucide-react";
|
||||
import { useFoundryTokens } from "../app/theme";
|
||||
import { logger } from "../logging.js";
|
||||
|
||||
|
|
@ -75,6 +77,59 @@ function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openD
|
|||
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId;
|
||||
}
|
||||
|
||||
function githubInstallationWarningTitle(organization: FoundryOrganization): string {
|
||||
return organization.github.installationStatus === "install_required" ? "GitHub App not installed" : "GitHub App needs reconnection";
|
||||
}
|
||||
|
||||
function githubInstallationWarningDetail(organization: FoundryOrganization): string {
|
||||
const statusDetail = organization.github.lastSyncLabel.trim();
|
||||
const requirementDetail =
|
||||
organization.github.installationStatus === "install_required"
|
||||
? "Webhooks are required for Foundry to function. Repo sync and PR updates will not work until the GitHub App is installed for this workspace."
|
||||
: "Webhook delivery is unavailable. Repo sync and PR updates will not work until the GitHub App is reconnected.";
|
||||
return statusDetail ? `${requirementDetail} ${statusDetail}.` : requirementDetail;
|
||||
}
|
||||
|
||||
function GithubInstallationWarning({
|
||||
organization,
|
||||
css,
|
||||
t,
|
||||
}: {
|
||||
organization: FoundryOrganization;
|
||||
css: ReturnType<typeof useStyletron>[0];
|
||||
t: ReturnType<typeof useFoundryTokens>;
|
||||
}) {
|
||||
if (organization.github.installationStatus === "connected") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
left: "8px",
|
||||
zIndex: 99998,
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: "8px",
|
||||
padding: "10px 12px",
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.statusError}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
maxWidth: "440px",
|
||||
})}
|
||||
>
|
||||
<CircleAlert size={15} color={t.statusError} />
|
||||
<div className={css({ display: "flex", flexDirection: "column", gap: "3px" })}>
|
||||
<div className={css({ fontSize: "11px", fontWeight: 600, color: t.textPrimary })}>{githubInstallationWarningTitle(organization)}</div>
|
||||
<div className={css({ fontSize: "11px", lineHeight: 1.45, color: t.textMuted })}>{githubInstallationWarningDetail(organization)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toLegacyTab(
|
||||
summary: WorkbenchSessionSummary,
|
||||
sessionDetail?: { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] },
|
||||
|
|
@ -125,6 +180,40 @@ function toLegacyTask(
|
|||
};
|
||||
}
|
||||
|
||||
const OPEN_PR_TASK_PREFIX = "pr:";
|
||||
|
||||
function openPrTaskId(prId: string): string {
|
||||
return `${OPEN_PR_TASK_PREFIX}${prId}`;
|
||||
}
|
||||
|
||||
function isOpenPrTaskId(taskId: string): boolean {
|
||||
return taskId.startsWith(OPEN_PR_TASK_PREFIX);
|
||||
}
|
||||
|
||||
function toLegacyOpenPrTask(pullRequest: WorkbenchOpenPrSummary): Task {
|
||||
return {
|
||||
id: openPrTaskId(pullRequest.prId),
|
||||
repoId: pullRequest.repoId,
|
||||
title: pullRequest.title,
|
||||
status: "new",
|
||||
runtimeStatus: undefined,
|
||||
statusMessage: pullRequest.authorLogin ? `@${pullRequest.authorLogin}` : null,
|
||||
repoName: pullRequest.repoFullName,
|
||||
updatedAtMs: pullRequest.updatedAtMs,
|
||||
branch: pullRequest.headRefName,
|
||||
pullRequest: {
|
||||
number: pullRequest.number,
|
||||
status: pullRequest.isDraft ? "draft" : "ready",
|
||||
},
|
||||
tabs: [],
|
||||
fileChanges: [],
|
||||
diffs: {},
|
||||
fileTree: [],
|
||||
minutesUsed: 0,
|
||||
activeSandboxId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionStateMessage(tab: Task["tabs"][number] | null | undefined): string | null {
|
||||
if (!tab) {
|
||||
return null;
|
||||
|
|
@ -153,7 +242,14 @@ function groupProjects(repos: Array<{ id: string; label: string }>, tasks: Task[
|
|||
}
|
||||
|
||||
interface WorkbenchActions {
|
||||
createTask(input: { repoId: string; task: string; title?: string; branch?: string; model?: ModelId }): Promise<{ taskId: string; tabId?: string }>;
|
||||
createTask(input: {
|
||||
repoId: string;
|
||||
task: string;
|
||||
title?: string;
|
||||
branch?: string;
|
||||
onBranch?: string;
|
||||
model?: ModelId;
|
||||
}): Promise<{ taskId: string; tabId?: string }>;
|
||||
markTaskUnread(input: { taskId: string }): Promise<void>;
|
||||
renameTask(input: { taskId: string; value: string }): Promise<void>;
|
||||
renameBranch(input: { taskId: string; value: string }): Promise<void>;
|
||||
|
|
@ -168,6 +264,10 @@ interface WorkbenchActions {
|
|||
closeTab(input: { taskId: string; tabId: string }): Promise<void>;
|
||||
addTab(input: { taskId: string; model?: string }): Promise<{ tabId: string }>;
|
||||
changeModel(input: { taskId: string; tabId: string; model: ModelId }): Promise<void>;
|
||||
reloadGithubOrganization(): Promise<void>;
|
||||
reloadGithubPullRequests(): Promise<void>;
|
||||
reloadGithubRepository(repoId: string): Promise<void>;
|
||||
reloadGithubPullRequest(repoId: string, prNumber: number): Promise<void>;
|
||||
}
|
||||
|
||||
const TranscriptPanel = memo(function TranscriptPanel({
|
||||
|
|
@ -187,6 +287,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onSidebarPeekEnd,
|
||||
rightSidebarCollapsed,
|
||||
onToggleRightSidebar,
|
||||
selectedSessionHydrating = false,
|
||||
onNavigateToUsage,
|
||||
}: {
|
||||
taskWorkbenchClient: WorkbenchActions;
|
||||
|
|
@ -205,6 +306,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onSidebarPeekEnd?: () => void;
|
||||
rightSidebarCollapsed?: boolean;
|
||||
onToggleRightSidebar?: () => void;
|
||||
selectedSessionHydrating?: boolean;
|
||||
onNavigateToUsage?: () => void;
|
||||
}) {
|
||||
const t = useFoundryTokens();
|
||||
|
|
@ -216,6 +318,11 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
const [pendingHistoryTarget, setPendingHistoryTarget] = useState<{ messageId: string; tabId: string } | null>(null);
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
||||
const [timerNowMs, setTimerNowMs] = useState(() => Date.now());
|
||||
const [localDraft, setLocalDraft] = useState("");
|
||||
const [localAttachments, setLocalAttachments] = useState<LineAttachment[]>([]);
|
||||
const lastEditTimeRef = useRef(0);
|
||||
const throttleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingDraftRef = useRef<{ text: string; attachments: LineAttachment[] } | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const messageRefs = useRef(new Map<string, HTMLDivElement>());
|
||||
|
|
@ -235,8 +342,27 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
!!activeAgentTab &&
|
||||
(activeAgentTab.status === "pending_provision" || activeAgentTab.status === "pending_session_create" || activeAgentTab.status === "error") &&
|
||||
activeMessages.length === 0;
|
||||
const draft = promptTab?.draft.text ?? "";
|
||||
const attachments = promptTab?.draft.attachments ?? [];
|
||||
const serverDraft = promptTab?.draft.text ?? "";
|
||||
const serverAttachments = promptTab?.draft.attachments ?? [];
|
||||
|
||||
// Sync server → local only when user hasn't typed recently (3s cooldown)
|
||||
const DRAFT_SYNC_COOLDOWN_MS = 3_000;
|
||||
useEffect(() => {
|
||||
if (Date.now() - lastEditTimeRef.current > DRAFT_SYNC_COOLDOWN_MS) {
|
||||
setLocalDraft(serverDraft);
|
||||
setLocalAttachments(serverAttachments);
|
||||
}
|
||||
}, [serverDraft, serverAttachments]);
|
||||
|
||||
// Reset local draft immediately on tab/task switch
|
||||
useEffect(() => {
|
||||
lastEditTimeRef.current = 0;
|
||||
setLocalDraft(promptTab?.draft.text ?? "");
|
||||
setLocalAttachments(promptTab?.draft.attachments ?? []);
|
||||
}, [promptTab?.id, task.id]);
|
||||
|
||||
const draft = localDraft;
|
||||
const attachments = localAttachments;
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
|
|
@ -343,20 +469,53 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
[editValue, task.id],
|
||||
);
|
||||
|
||||
const DRAFT_THROTTLE_MS = 500;
|
||||
|
||||
const flushDraft = useCallback(
|
||||
(text: string, nextAttachments: LineAttachment[], tabId: string) => {
|
||||
void taskWorkbenchClient.updateDraft({
|
||||
taskId: task.id,
|
||||
tabId,
|
||||
text,
|
||||
attachments: nextAttachments,
|
||||
});
|
||||
},
|
||||
[task.id],
|
||||
);
|
||||
|
||||
// Clean up throttle timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (throttleTimerRef.current) {
|
||||
clearTimeout(throttleTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateDraft = useCallback(
|
||||
(nextText: string, nextAttachments: LineAttachment[]) => {
|
||||
if (!promptTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
void taskWorkbenchClient.updateDraft({
|
||||
taskId: task.id,
|
||||
tabId: promptTab.id,
|
||||
text: nextText,
|
||||
attachments: nextAttachments,
|
||||
});
|
||||
// Update local state immediately for responsive typing
|
||||
lastEditTimeRef.current = Date.now();
|
||||
setLocalDraft(nextText);
|
||||
setLocalAttachments(nextAttachments);
|
||||
|
||||
// Throttle the network call
|
||||
pendingDraftRef.current = { text: nextText, attachments: nextAttachments };
|
||||
if (!throttleTimerRef.current) {
|
||||
throttleTimerRef.current = setTimeout(() => {
|
||||
throttleTimerRef.current = null;
|
||||
if (pendingDraftRef.current) {
|
||||
flushDraft(pendingDraftRef.current.text, pendingDraftRef.current.attachments, promptTab.id);
|
||||
pendingDraftRef.current = null;
|
||||
}
|
||||
}, DRAFT_THROTTLE_MS);
|
||||
}
|
||||
},
|
||||
[task.id, promptTab],
|
||||
[promptTab, flushDraft],
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(() => {
|
||||
|
|
@ -687,6 +846,33 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
</div>
|
||||
</div>
|
||||
</ScrollBody>
|
||||
) : selectedSessionHydrating ? (
|
||||
<ScrollBody>
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "32px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "420px",
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<SpinnerDot size={16} />
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Loading session</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>Fetching the latest transcript for this session.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollBody>
|
||||
) : showPendingSessionState ? (
|
||||
<ScrollBody>
|
||||
<div
|
||||
|
|
@ -1099,12 +1285,25 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
closeTab: (input) => backendClient.closeWorkbenchSession(workspaceId, input),
|
||||
addTab: (input) => backendClient.createWorkbenchSession(workspaceId, input),
|
||||
changeModel: (input) => backendClient.changeWorkbenchModel(workspaceId, input),
|
||||
reloadGithubOrganization: () => backendClient.reloadGithubOrganization(workspaceId),
|
||||
reloadGithubPullRequests: () => backendClient.reloadGithubPullRequests(workspaceId),
|
||||
reloadGithubRepository: (repoId) => backendClient.reloadGithubRepository(workspaceId, repoId),
|
||||
reloadGithubPullRequest: (repoId, prNumber) => backendClient.reloadGithubPullRequest(workspaceId, repoId, prNumber),
|
||||
}),
|
||||
[workspaceId],
|
||||
);
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
const workspaceRepos = workspaceState.data?.repos ?? [];
|
||||
const taskSummaries = workspaceState.data?.taskSummaries ?? [];
|
||||
const openPullRequests = workspaceState.data?.openPullRequests ?? [];
|
||||
const openPullRequestsByTaskId = useMemo(
|
||||
() => new Map(openPullRequests.map((pullRequest) => [openPrTaskId(pullRequest.prId), pullRequest])),
|
||||
[openPullRequests],
|
||||
);
|
||||
const selectedOpenPullRequest = useMemo(
|
||||
() => (selectedTaskId ? (openPullRequestsByTaskId.get(selectedTaskId) ?? null) : null),
|
||||
[openPullRequestsByTaskId, selectedTaskId],
|
||||
);
|
||||
const selectedTaskSummary = useMemo(
|
||||
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
|
||||
[selectedTaskId, taskSummaries],
|
||||
|
|
@ -1169,10 +1368,12 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
}
|
||||
}
|
||||
|
||||
return taskSummaries.map((summary) =>
|
||||
const legacyTasks = taskSummaries.map((summary) =>
|
||||
summary.id === selectedTaskSummary?.id ? toLegacyTask(summary, taskState.data, sessionCache) : toLegacyTask(summary),
|
||||
);
|
||||
}, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, workspaceId]);
|
||||
const legacyOpenPrs = openPullRequests.map((pullRequest) => toLegacyOpenPrTask(pullRequest));
|
||||
return [...legacyTasks, ...legacyOpenPrs].sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||
}, [openPullRequests, selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, workspaceId]);
|
||||
const rawProjects = useMemo(() => groupProjects(workspaceRepos, tasks), [tasks, workspaceRepos]);
|
||||
const appSnapshot = useMockAppSnapshot();
|
||||
const activeOrg = activeMockOrganization(appSnapshot);
|
||||
|
|
@ -1200,9 +1401,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
const leftWidthRef = useRef(leftWidth);
|
||||
const rightWidthRef = useRef(rightWidth);
|
||||
const autoCreatingSessionForTaskRef = useRef<Set<string>>(new Set());
|
||||
const resolvingOpenPullRequestsRef = useRef<Set<string>>(new Set());
|
||||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(true);
|
||||
const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false);
|
||||
const [materializingOpenPrId, setMaterializingOpenPrId] = useState<string | null>(null);
|
||||
const showDevPanel = useDevPanel();
|
||||
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
@ -1268,13 +1471,81 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
startRightRef.current = rightWidthRef.current;
|
||||
}, []);
|
||||
|
||||
const activeTask = useMemo(() => tasks.find((task) => task.id === selectedTaskId) ?? tasks[0] ?? null, [tasks, selectedTaskId]);
|
||||
const activeTask = useMemo(() => {
|
||||
const realTasks = tasks.filter((task) => !isOpenPrTaskId(task.id));
|
||||
if (selectedOpenPullRequest) {
|
||||
return null;
|
||||
}
|
||||
if (selectedTaskId) {
|
||||
return realTasks.find((task) => task.id === selectedTaskId) ?? realTasks[0] ?? null;
|
||||
}
|
||||
return realTasks[0] ?? null;
|
||||
}, [selectedOpenPullRequest, selectedTaskId, tasks]);
|
||||
|
||||
const materializeOpenPullRequest = useCallback(
|
||||
async (pullRequest: WorkbenchOpenPrSummary) => {
|
||||
if (resolvingOpenPullRequestsRef.current.has(pullRequest.prId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
resolvingOpenPullRequestsRef.current.add(pullRequest.prId);
|
||||
setMaterializingOpenPrId(pullRequest.prId);
|
||||
|
||||
try {
|
||||
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
||||
repoId: pullRequest.repoId,
|
||||
task: `Continue work on GitHub PR #${pullRequest.number}: ${pullRequest.title}`,
|
||||
model: "gpt-5.3-codex",
|
||||
title: pullRequest.title,
|
||||
onBranch: pullRequest.headRefName,
|
||||
});
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
taskId,
|
||||
},
|
||||
search: { sessionId: tabId ?? undefined },
|
||||
replace: true,
|
||||
});
|
||||
} catch (error) {
|
||||
setMaterializingOpenPrId((current) => (current === pullRequest.prId ? null : current));
|
||||
resolvingOpenPullRequestsRef.current.delete(pullRequest.prId);
|
||||
logger.error(
|
||||
{
|
||||
prId: pullRequest.prId,
|
||||
repoId: pullRequest.repoId,
|
||||
branchName: pullRequest.headRefName,
|
||||
...createErrorContext(error),
|
||||
},
|
||||
"failed_to_materialize_open_pull_request_task",
|
||||
);
|
||||
}
|
||||
},
|
||||
[navigate, taskWorkbenchClient, workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedOpenPullRequest) {
|
||||
if (materializingOpenPrId) {
|
||||
resolvingOpenPullRequestsRef.current.delete(materializingOpenPrId);
|
||||
}
|
||||
setMaterializingOpenPrId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void materializeOpenPullRequest(selectedOpenPullRequest);
|
||||
}, [materializeOpenPullRequest, materializingOpenPrId, selectedOpenPullRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedOpenPullRequest || materializingOpenPrId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackTaskId = tasks[0]?.id;
|
||||
if (!fallbackTaskId) {
|
||||
return;
|
||||
|
|
@ -1291,11 +1562,12 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
search: { sessionId: fallbackTask?.tabs[0]?.id ?? undefined },
|
||||
replace: true,
|
||||
});
|
||||
}, [activeTask, tasks, navigate, workspaceId]);
|
||||
}, [activeTask, materializingOpenPrId, navigate, selectedOpenPullRequest, tasks, workspaceId]);
|
||||
|
||||
const openDiffs = activeTask ? sanitizeOpenDiffs(activeTask, openDiffsByTask[activeTask.id]) : [];
|
||||
const lastAgentTabId = activeTask ? sanitizeLastAgentTabId(activeTask, lastAgentTabIdByTask[activeTask.id]) : null;
|
||||
const activeTabId = activeTask ? sanitizeActiveTabId(activeTask, activeTabIdByTask[activeTask.id], openDiffs, lastAgentTabId) : null;
|
||||
const selectedSessionHydrating = Boolean(selectedSessionId && activeTabId === selectedSessionId && sessionState.status === "loading" && !sessionState.data);
|
||||
|
||||
const syncRouteSession = useCallback(
|
||||
(taskId: string, sessionId: string | null, replace = false) => {
|
||||
|
|
@ -1395,7 +1667,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
|
||||
|
||||
const createTask = useCallback(
|
||||
(overrideRepoId?: string) => {
|
||||
(overrideRepoId?: string, options?: { title?: string; task?: string; branch?: string; onBranch?: string }) => {
|
||||
void (async () => {
|
||||
const repoId = overrideRepoId || selectedNewTaskRepoId;
|
||||
if (!repoId) {
|
||||
|
|
@ -1404,9 +1676,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
|
||||
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
||||
repoId,
|
||||
task: "New task",
|
||||
task: options?.task ?? "New task",
|
||||
model: "gpt-5.3-codex",
|
||||
title: "New task",
|
||||
title: options?.title ?? "New task",
|
||||
...(options?.branch ? { branch: options.branch } : {}),
|
||||
...(options?.onBranch ? { onBranch: options.onBranch } : {}),
|
||||
});
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
|
|
@ -1418,7 +1692,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
});
|
||||
})();
|
||||
},
|
||||
[navigate, selectedNewTaskRepoId, workspaceId],
|
||||
[navigate, selectedNewTaskRepoId, taskWorkbenchClient, workspaceId],
|
||||
);
|
||||
|
||||
const openDiffTab = useCallback(
|
||||
|
|
@ -1447,6 +1721,14 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
|
||||
const selectTask = useCallback(
|
||||
(id: string) => {
|
||||
if (isOpenPrTaskId(id)) {
|
||||
const pullRequest = openPullRequestsByTaskId.get(id);
|
||||
if (!pullRequest) {
|
||||
return;
|
||||
}
|
||||
void materializeOpenPullRequest(pullRequest);
|
||||
return;
|
||||
}
|
||||
const task = tasks.find((candidate) => candidate.id === id) ?? null;
|
||||
void navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
|
|
@ -1457,7 +1739,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
search: { sessionId: task?.tabs[0]?.id ?? undefined },
|
||||
});
|
||||
},
|
||||
[tasks, navigate, workspaceId],
|
||||
[materializeOpenPullRequest, navigate, openPullRequestsByTaskId, tasks, workspaceId],
|
||||
);
|
||||
|
||||
const markTaskUnread = useCallback((id: string) => {
|
||||
|
|
@ -1616,6 +1898,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
};
|
||||
|
||||
if (!activeTask) {
|
||||
const isMaterializingSelectedOpenPr = Boolean(selectedOpenPullRequest) || materializingOpenPrId != null;
|
||||
return (
|
||||
<>
|
||||
{dragRegion}
|
||||
|
|
@ -1636,7 +1919,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
projects={projects}
|
||||
newTaskRepos={workspaceRepos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId=""
|
||||
activeId={selectedTaskId ?? ""}
|
||||
onSelect={selectTask}
|
||||
onCreate={createTask}
|
||||
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
|
||||
|
|
@ -1646,6 +1929,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
onReorderProjects={reorderProjects}
|
||||
taskOrderByProject={taskOrderByProject}
|
||||
onReorderTasks={reorderTasks}
|
||||
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
||||
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
||||
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
||||
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
|
||||
onToggleSidebar={() => setLeftSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1712,6 +1999,14 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
{activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.</>}
|
||||
</p>
|
||||
</>
|
||||
) : isMaterializingSelectedOpenPr && selectedOpenPullRequest ? (
|
||||
<>
|
||||
<SpinnerDot />
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Creating task from pull request</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>
|
||||
Preparing a task for <strong>{selectedOpenPullRequest.title}</strong> on <strong>{selectedOpenPullRequest.headRefName}</strong>.
|
||||
</p>
|
||||
</>
|
||||
) : activeOrg?.github.syncStatus === "error" ? (
|
||||
<>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600, color: t.statusError }}>GitHub sync failed</h2>
|
||||
|
|
@ -1766,40 +2061,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
{activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
left: "8px",
|
||||
zIndex: 99998,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 12px",
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.statusError}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
fontSize: "11px",
|
||||
color: t.textPrimary,
|
||||
maxWidth: "360px",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: t.statusError,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
|
||||
{showDevPanel && (
|
||||
<DevPanel
|
||||
workspaceId={workspaceId}
|
||||
|
|
@ -1832,7 +2094,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
projects={projects}
|
||||
newTaskRepos={workspaceRepos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId={activeTask.id}
|
||||
activeId={selectedTaskId ?? activeTask.id}
|
||||
onSelect={selectTask}
|
||||
onCreate={createTask}
|
||||
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
|
||||
|
|
@ -1842,6 +2104,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
onReorderProjects={reorderProjects}
|
||||
taskOrderByProject={taskOrderByProject}
|
||||
onReorderTasks={reorderTasks}
|
||||
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
||||
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
||||
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
||||
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
|
||||
onToggleSidebar={() => setLeftSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1880,7 +2146,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
projects={projects}
|
||||
newTaskRepos={workspaceRepos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId={activeTask.id}
|
||||
activeId={selectedTaskId ?? activeTask.id}
|
||||
onSelect={(id) => {
|
||||
selectTask(id);
|
||||
setLeftSidebarPeeking(false);
|
||||
|
|
@ -1893,6 +2159,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
onReorderProjects={reorderProjects}
|
||||
taskOrderByProject={taskOrderByProject}
|
||||
onReorderTasks={reorderTasks}
|
||||
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
||||
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
||||
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
||||
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
|
||||
onToggleSidebar={() => {
|
||||
setLeftSidebarPeeking(false);
|
||||
setLeftSidebarOpen(true);
|
||||
|
|
@ -1930,6 +2200,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
onSidebarPeekEnd={endPeek}
|
||||
rightSidebarCollapsed={!rightSidebarOpen}
|
||||
onToggleRightSidebar={() => setRightSidebarOpen(true)}
|
||||
selectedSessionHydrating={selectedSessionHydrating}
|
||||
onNavigateToUsage={navigateToUsage}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1959,40 +2230,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
left: "8px",
|
||||
zIndex: 99998,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 12px",
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.statusError}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
fontSize: "11px",
|
||||
color: t.textPrimary,
|
||||
maxWidth: "360px",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: t.statusError,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
|
||||
{showDevPanel && (
|
||||
<DevPanel
|
||||
workspaceId={workspaceId}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }:
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
padding: "6px 8px",
|
||||
|
|
@ -110,7 +110,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }:
|
|||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
transition: "background 160ms ease, color 160ms ease",
|
||||
transition: "background-color 160ms ease, color 160ms ease",
|
||||
":hover": {
|
||||
backgroundColor: t.interactiveHover,
|
||||
color: t.textPrimary,
|
||||
|
|
|
|||
|
|
@ -146,7 +146,6 @@ export const ModelPicker = memo(function ModelPicker({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
display: "flex",
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@ const FileTree = memo(function FileTree({
|
|||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "3px 10px",
|
||||
paddingTop: "3px",
|
||||
paddingRight: "10px",
|
||||
paddingBottom: "3px",
|
||||
paddingLeft: `${10 + depth * 16}px`,
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
|
|
@ -175,7 +177,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
|
|
@ -202,7 +204,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
|
|
@ -230,7 +232,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
|
|
@ -312,17 +314,16 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
marginTop: "6px",
|
||||
marginRight: "0",
|
||||
marginBottom: "6px",
|
||||
marginLeft: "6px",
|
||||
boxSizing: "border-box",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "4px 12px",
|
||||
marginTop: "6px",
|
||||
marginBottom: "6px",
|
||||
marginLeft: "6px",
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
|
|
@ -363,15 +364,15 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
marginTop: "6px",
|
||||
marginRight: "0",
|
||||
marginBottom: "6px",
|
||||
marginLeft: "0",
|
||||
boxSizing: "border-box",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 12px",
|
||||
marginTop: "6px",
|
||||
marginBottom: "6px",
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
GitPullRequestDraft,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
MoreHorizontal,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
Settings,
|
||||
|
|
@ -52,6 +53,10 @@ function projectIconColor(label: string): string {
|
|||
return PROJECT_COLORS[Math.abs(hash) % PROJECT_COLORS.length]!;
|
||||
}
|
||||
|
||||
function isPullRequestSidebarItem(task: Task): boolean {
|
||||
return task.id.startsWith("pr:");
|
||||
}
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
projects,
|
||||
newTaskRepos,
|
||||
|
|
@ -66,6 +71,10 @@ export const Sidebar = memo(function Sidebar({
|
|||
onReorderProjects,
|
||||
taskOrderByProject,
|
||||
onReorderTasks,
|
||||
onReloadOrganization,
|
||||
onReloadPullRequests,
|
||||
onReloadRepository,
|
||||
onReloadPullRequest,
|
||||
onToggleSidebar,
|
||||
}: {
|
||||
projects: ProjectSection[];
|
||||
|
|
@ -81,6 +90,10 @@ export const Sidebar = memo(function Sidebar({
|
|||
onReorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
taskOrderByProject: Record<string, string[]>;
|
||||
onReorderTasks: (projectId: string, fromIndex: number, toIndex: number) => void;
|
||||
onReloadOrganization: () => void;
|
||||
onReloadPullRequests: () => void;
|
||||
onReloadRepository: (repoId: string) => void;
|
||||
onReloadPullRequest: (repoId: string, prNumber: number) => void;
|
||||
onToggleSidebar?: () => void;
|
||||
}) {
|
||||
const [css] = useStyletron();
|
||||
|
|
@ -88,6 +101,8 @@ export const Sidebar = memo(function Sidebar({
|
|||
const contextMenu = useContextMenu();
|
||||
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({});
|
||||
const [hoveredProjectId, setHoveredProjectId] = useState<string | null>(null);
|
||||
const [headerMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const headerMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Mouse-based drag and drop state
|
||||
type DragState =
|
||||
|
|
@ -149,6 +164,20 @@ export const Sidebar = memo(function Sidebar({
|
|||
};
|
||||
}, [drag, onReorderProjects, onReorderTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!headerMenuOpen) {
|
||||
return;
|
||||
}
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
if (headerMenuRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setHeaderMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||
}, [headerMenuOpen]);
|
||||
|
||||
const [createSelectOpen, setCreateSelectOpen] = useState(false);
|
||||
const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]);
|
||||
|
||||
|
|
@ -326,47 +355,111 @@ export const Sidebar = memo(function Sidebar({
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-disabled={newTaskRepos.length === 0}
|
||||
onClick={() => {
|
||||
if (newTaskRepos.length === 0) return;
|
||||
if (newTaskRepos.length === 1) {
|
||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||
onCreate(newTaskRepos[0]!.id);
|
||||
} else {
|
||||
setCreateSelectOpen(true);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (newTaskRepos.length === 0) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "6px", position: "relative" })} ref={headerMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHeaderMenuOpen((value) => !value)}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
backgroundColor: t.interactiveHover,
|
||||
color: t.textPrimary,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "background 200ms ease",
|
||||
flexShrink: 0,
|
||||
":hover": { backgroundColor: t.borderMedium },
|
||||
})}
|
||||
title="GitHub actions"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{headerMenuOpen ? (
|
||||
<div
|
||||
className={css({
|
||||
position: "absolute",
|
||||
top: "32px",
|
||||
right: 0,
|
||||
minWidth: "180px",
|
||||
padding: "6px",
|
||||
borderRadius: "10px",
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
boxShadow: `${t.shadow}, 0 0 0 1px ${t.interactiveSubtle}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
zIndex: 20,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHeaderMenuOpen(false);
|
||||
onReloadOrganization();
|
||||
}}
|
||||
className={css(menuButtonStyle(false, t))}
|
||||
>
|
||||
Reload organization
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHeaderMenuOpen(false);
|
||||
onReloadPullRequests();
|
||||
}}
|
||||
className={css(menuButtonStyle(false, t))}
|
||||
>
|
||||
Reload all PRs
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-disabled={newTaskRepos.length === 0}
|
||||
onClick={() => {
|
||||
if (newTaskRepos.length === 0) return;
|
||||
if (newTaskRepos.length === 1) {
|
||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||
onCreate(newTaskRepos[0]!.id);
|
||||
} else {
|
||||
setCreateSelectOpen(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: newTaskRepos.length > 0 ? t.borderMedium : t.interactiveHover,
|
||||
color: t.textPrimary,
|
||||
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "background 200ms ease",
|
||||
flexShrink: 0,
|
||||
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
|
||||
":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined,
|
||||
})}
|
||||
>
|
||||
<Plus size={14} style={{ display: "block" }} />
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (newTaskRepos.length === 0) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
if (newTaskRepos.length === 1) {
|
||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||
onCreate(newTaskRepos[0]!.id);
|
||||
} else {
|
||||
setCreateSelectOpen(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: newTaskRepos.length > 0 ? t.borderMedium : t.interactiveHover,
|
||||
color: t.textPrimary,
|
||||
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "background 200ms ease",
|
||||
flexShrink: 0,
|
||||
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
|
||||
":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined,
|
||||
})}
|
||||
>
|
||||
<Plus size={14} style={{ display: "block" }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PanelHeaderBar>
|
||||
|
|
@ -431,6 +524,12 @@ export const Sidebar = memo(function Sidebar({
|
|||
}));
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) =>
|
||||
contextMenu.open(event, [
|
||||
{ label: "Reload repository", onClick: () => onReloadRepository(project.id) },
|
||||
{ label: "New task", onClick: () => onCreate(project.id) },
|
||||
])
|
||||
}
|
||||
data-project-header
|
||||
className={css({
|
||||
display: "flex",
|
||||
|
|
@ -499,13 +598,13 @@ export const Sidebar = memo(function Sidebar({
|
|||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
background: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: "pointer",
|
||||
color: t.textTertiary,
|
||||
opacity: hoveredProjectId === project.id ? 1 : 0,
|
||||
transition: "opacity 150ms ease, background 200ms ease, color 200ms ease",
|
||||
transition: "opacity 150ms ease, background-color 200ms ease, color 200ms ease",
|
||||
pointerEvents: hoveredProjectId === project.id ? "auto" : "none",
|
||||
":hover": { backgroundColor: t.interactiveHover, color: t.textSecondary },
|
||||
})}
|
||||
|
|
@ -519,12 +618,14 @@ export const Sidebar = memo(function Sidebar({
|
|||
{!isCollapsed &&
|
||||
orderedTasks.map((task, taskIndex) => {
|
||||
const isActive = task.id === activeId;
|
||||
const isPullRequestItem = isPullRequestSidebarItem(task);
|
||||
const isDim = task.status === "archived";
|
||||
const isRunning = task.tabs.some((tab) => tab.status === "running");
|
||||
const isProvisioning =
|
||||
String(task.status).startsWith("init_") ||
|
||||
task.status === "new" ||
|
||||
task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create");
|
||||
!isPullRequestItem &&
|
||||
(String(task.status).startsWith("init_") ||
|
||||
task.status === "new" ||
|
||||
task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create"));
|
||||
const hasUnread = task.tabs.some((tab) => tab.unread);
|
||||
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
|
||||
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
|
||||
|
|
@ -554,13 +655,20 @@ export const Sidebar = memo(function Sidebar({
|
|||
onSelect(task.id);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) =>
|
||||
onContextMenu={(event) => {
|
||||
if (isPullRequestItem && task.pullRequest) {
|
||||
contextMenu.open(event, [
|
||||
{ label: "Reload pull request", onClick: () => onReloadPullRequest(task.repoId, task.pullRequest!.number) },
|
||||
{ label: "Create task", onClick: () => onSelect(task.id) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
contextMenu.open(event, [
|
||||
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
|
||||
{ label: "Rename branch", onClick: () => onRenameBranch(task.id) },
|
||||
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
|
||||
])
|
||||
}
|
||||
]);
|
||||
}}
|
||||
className={css({
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
|
|
@ -596,21 +704,32 @@ export const Sidebar = memo(function Sidebar({
|
|||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
|
||||
{isPullRequestItem ? (
|
||||
<GitPullRequestDraft size={13} color={isDraft ? t.accent : t.textSecondary} />
|
||||
) : (
|
||||
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
|
||||
)}
|
||||
</div>
|
||||
<div className={css({ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: "1px" })}>
|
||||
<LabelSmall
|
||||
$style={{
|
||||
fontWeight: hasUnread ? 600 : 400,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
color={hasUnread ? t.textPrimary : t.textSecondary}
|
||||
>
|
||||
{task.title}
|
||||
</LabelSmall>
|
||||
{isPullRequestItem && task.statusMessage ? (
|
||||
<LabelXSmall color={t.textTertiary} $style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{task.statusMessage}
|
||||
</LabelXSmall>
|
||||
) : null}
|
||||
</div>
|
||||
<LabelSmall
|
||||
$style={{
|
||||
fontWeight: hasUnread ? 600 : 400,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
color={hasUnread ? t.textPrimary : t.textSecondary}
|
||||
>
|
||||
{task.title}
|
||||
</LabelSmall>
|
||||
{task.pullRequest != null ? (
|
||||
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
||||
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>
|
||||
|
|
|
|||
|
|
@ -543,7 +543,10 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
gap: "6px",
|
||||
minHeight: "39px",
|
||||
maxHeight: "39px",
|
||||
padding: "0 14px",
|
||||
paddingTop: "0",
|
||||
paddingRight: "14px",
|
||||
paddingBottom: "0",
|
||||
paddingLeft: "14px",
|
||||
borderTop: `1px solid ${t.borderDefault}`,
|
||||
backgroundColor: t.surfacePrimary,
|
||||
flexShrink: 0,
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@ export const TranscriptHeader = memo(function TranscriptHeader({
|
|||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
margin: "0",
|
||||
outline: "none",
|
||||
padding: "2px 8px",
|
||||
|
|
|
|||
|
|
@ -299,7 +299,10 @@ export const PanelHeaderBar = styled("div", ({ $theme }) => {
|
|||
alignItems: "center",
|
||||
minHeight: HEADER_HEIGHT,
|
||||
maxHeight: HEADER_HEIGHT,
|
||||
padding: "0 14px",
|
||||
paddingTop: "0",
|
||||
paddingRight: "14px",
|
||||
paddingBottom: "0",
|
||||
paddingLeft: "14px",
|
||||
borderBottom: `1px solid ${t.borderDefault}`,
|
||||
backgroundColor: t.surfaceTertiary,
|
||||
gap: "8px",
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ export interface FoundryGithubState {
|
|||
importedRepoCount: number;
|
||||
lastSyncLabel: string;
|
||||
lastSyncAt: number | null;
|
||||
lastWebhookAt: number | null;
|
||||
lastWebhookEvent: string;
|
||||
}
|
||||
|
||||
export interface FoundryOrganizationSettings {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { FoundryAppSnapshot } from "./app-shell.js";
|
||||
import type { WorkbenchRepoSummary, WorkbenchSessionDetail, WorkbenchTaskDetail, WorkbenchTaskSummary } from "./workbench.js";
|
||||
import type { WorkbenchOpenPrSummary, WorkbenchRepoSummary, WorkbenchSessionDetail, WorkbenchTaskDetail, WorkbenchTaskSummary } from "./workbench.js";
|
||||
|
||||
export interface SandboxProcessSnapshot {
|
||||
id: string;
|
||||
|
|
@ -21,7 +21,9 @@ export type WorkspaceEvent =
|
|||
| { type: "taskRemoved"; taskId: string }
|
||||
| { type: "repoAdded"; repo: WorkbenchRepoSummary }
|
||||
| { type: "repoUpdated"; repo: WorkbenchRepoSummary }
|
||||
| { type: "repoRemoved"; repoId: string };
|
||||
| { type: "repoRemoved"; repoId: string }
|
||||
| { type: "pullRequestUpdated"; pullRequest: WorkbenchOpenPrSummary }
|
||||
| { type: "pullRequestRemoved"; prId: string };
|
||||
|
||||
/** Task-level events broadcast by the task actor. */
|
||||
export type TaskEvent = { type: "taskDetailUpdated"; detail: WorkbenchTaskDetail };
|
||||
|
|
|
|||
|
|
@ -105,6 +105,21 @@ export interface WorkbenchPullRequestSummary {
|
|||
status: "draft" | "ready";
|
||||
}
|
||||
|
||||
export interface WorkbenchOpenPrSummary {
|
||||
prId: string;
|
||||
repoId: string;
|
||||
repoFullName: string;
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
authorLogin: string | null;
|
||||
isDraft: boolean;
|
||||
updatedAtMs: number;
|
||||
}
|
||||
|
||||
export interface WorkbenchSandboxSummary {
|
||||
providerId: ProviderId;
|
||||
sandboxId: string;
|
||||
|
|
@ -161,6 +176,7 @@ export interface WorkspaceSummarySnapshot {
|
|||
workspaceId: string;
|
||||
repos: WorkbenchRepoSummary[];
|
||||
taskSummaries: WorkbenchTaskSummary[];
|
||||
openPullRequests: WorkbenchOpenPrSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -229,6 +245,7 @@ export interface TaskWorkbenchCreateTaskInput {
|
|||
task: string;
|
||||
title?: string;
|
||||
branch?: string;
|
||||
onBranch?: string;
|
||||
model?: WorkbenchModelId;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue