chore(foundry): workbench action responsiveness (#254)

* wip

* wip
This commit is contained in:
Nathan Flurry 2026-03-14 20:42:18 -07:00 committed by GitHub
parent 400f9a214e
commit 99abb9d42e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
171 changed files with 7260 additions and 7342 deletions

View file

@ -1,51 +1,51 @@
import type { TaskStatus, ProviderId } from "@sandbox-agent/foundry-shared";
import type { TaskStatus, SandboxProviderId } from "@sandbox-agent/foundry-shared";
export interface TaskCreatedEvent {
workspaceId: string;
organizationId: string;
repoId: string;
taskId: string;
providerId: ProviderId;
sandboxProviderId: SandboxProviderId;
branchName: string;
title: string;
}
export interface TaskStatusEvent {
workspaceId: string;
organizationId: string;
repoId: string;
taskId: string;
status: TaskStatus;
message: string;
}
export interface ProjectSnapshotEvent {
workspaceId: string;
export interface RepositorySnapshotEvent {
organizationId: string;
repoId: string;
updatedAt: number;
}
export interface AgentStartedEvent {
workspaceId: string;
organizationId: string;
repoId: string;
taskId: string;
sessionId: string;
}
export interface AgentIdleEvent {
workspaceId: string;
organizationId: string;
repoId: string;
taskId: string;
sessionId: string;
}
export interface AgentErrorEvent {
workspaceId: string;
organizationId: string;
repoId: string;
taskId: string;
message: string;
}
export interface PrCreatedEvent {
workspaceId: string;
organizationId: string;
repoId: string;
taskId: string;
prNumber: number;
@ -53,7 +53,7 @@ export interface PrCreatedEvent {
}
export interface PrClosedEvent {
workspaceId: string;
organizationId: string;
repoId: string;
taskId: string;
prNumber: number;
@ -61,7 +61,7 @@ export interface PrClosedEvent {
}
export interface PrReviewEvent {
workspaceId: string;
organizationId: string;
repoId: string;
taskId: string;
prNumber: number;
@ -70,7 +70,7 @@ export interface PrReviewEvent {
}
export interface CiStatusChangedEvent {
workspaceId: string;
organizationId: string;
repoId: string;
taskId: string;
prNumber: number;
@ -81,7 +81,7 @@ export type TaskStepName = "auto_commit" | "push" | "pr_submit";
export type TaskStepStatus = "started" | "completed" | "skipped" | "failed";
export interface TaskStepEvent {
workspaceId: string;
organizationId: string;
repoId: string;
taskId: string;
step: TaskStepName;
@ -90,23 +90,15 @@ export interface TaskStepEvent {
}
export interface BranchSwitchedEvent {
workspaceId: string;
organizationId: string;
repoId: string;
taskId: string;
branchName: string;
}
export interface SessionAttachedEvent {
workspaceId: string;
organizationId: string;
repoId: string;
taskId: string;
sessionId: string;
}
export interface BranchSyncedEvent {
workspaceId: string;
repoId: string;
taskId: string;
branchName: string;
strategy: string;
}

View file

@ -6,6 +6,18 @@ const journal = {
tag: "0000_github_data",
breakpoints: true,
},
{
idx: 1,
when: 1773810002000,
tag: "0001_default_branch",
breakpoints: true,
},
{
idx: 2,
when: 1773810300000,
tag: "0002_github_branches",
breakpoints: true,
},
],
} as const;
@ -56,6 +68,16 @@ CREATE TABLE \`github_pull_requests\` (
\`is_draft\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0001: `ALTER TABLE \`github_repositories\` ADD \`default_branch\` text NOT NULL DEFAULT 'main';
`,
m0002: `CREATE TABLE \`github_branches\` (
\`branch_id\` text PRIMARY KEY NOT NULL,
\`repo_id\` text NOT NULL,
\`branch_name\` text NOT NULL,
\`commit_sha\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
} as const,
};

View file

@ -16,6 +16,15 @@ export const githubRepositories = sqliteTable("github_repositories", {
fullName: text("full_name").notNull(),
cloneUrl: text("clone_url").notNull(),
private: integer("private").notNull(),
defaultBranch: text("default_branch").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const githubBranches = sqliteTable("github_branches", {
branchId: text("branch_id").notNull().primaryKey(),
repoId: text("repo_id").notNull(),
branchName: text("branch_name").notNull(),
commitSha: text("commit_sha").notNull(),
updatedAt: integer("updated_at").notNull(),
});

View file

@ -3,16 +3,16 @@ 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 { getOrCreateOrganization, getTask } from "../handles.js";
import { repoIdFromRemote } from "../../services/repo.js";
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
import { githubDataDb } from "./db/db.js";
import { githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js";
import { githubBranches, githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js";
const META_ROW_ID = 1;
interface GithubDataInput {
workspaceId: string;
organizationId: string;
}
interface GithubMemberRecord {
@ -28,6 +28,13 @@ interface GithubRepositoryRecord {
fullName: string;
cloneUrl: string;
private: boolean;
defaultBranch: string;
}
interface GithubBranchRecord {
repoId: string;
branchName: string;
commitSha: string;
}
interface GithubPullRequestRecord {
@ -156,21 +163,21 @@ async function writeMeta(c: any, patch: Partial<Awaited<ReturnType<typeof readMe
}
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 organizationHandle = await getOrCreateOrganization(c, c.state.organizationId);
const organizationState = await organizationHandle.getOrganizationShellStateIfInitialized({});
if (!organizationState) {
throw new Error(`Organization ${c.state.organizationId} is not initialized`);
}
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
const auth = await resolveOrganizationGithubAuth(c, c.state.organizationId);
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,
kind: overrides?.kind ?? organizationState.snapshot.kind,
githubLogin: overrides?.githubLogin ?? organizationState.githubLogin,
connectedAccount: overrides?.connectedAccount ?? organizationState.snapshot.github.connectedAccount ?? organizationState.githubLogin,
installationId: overrides?.installationId ?? organizationState.githubInstallationId ?? null,
installationStatus:
overrides?.installationStatus ??
organization.snapshot.github.installationStatus ??
(organization.snapshot.kind === "personal" ? "connected" : "reconnect_required"),
organizationState.snapshot.github.installationStatus ??
(organizationState.snapshot.kind === "personal" ? "connected" : "reconnect_required"),
accessToken: overrides?.accessToken ?? auth?.githubToken ?? null,
};
}
@ -185,6 +192,23 @@ async function replaceRepositories(c: any, repositories: GithubRepositoryRecord[
fullName: repository.fullName,
cloneUrl: repository.cloneUrl,
private: repository.private ? 1 : 0,
defaultBranch: repository.defaultBranch,
updatedAt,
})
.run();
}
}
async function replaceBranches(c: any, branches: GithubBranchRecord[], updatedAt: number) {
await c.db.delete(githubBranches).run();
for (const branch of branches) {
await c.db
.insert(githubBranches)
.values({
branchId: `${branch.repoId}:${branch.branchName}`,
repoId: branch.repoId,
branchName: branch.branchName,
commitSha: branch.commitSha,
updatedAt,
})
.run();
@ -234,12 +258,12 @@ async function replacePullRequests(c: any, pullRequests: GithubPullRequestRecord
}
async function refreshTaskSummaryForBranch(c: any, repoId: string, branchName: string) {
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
await workspace.refreshTaskSummaryForGithubBranch({ repoId, branchName });
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.refreshTaskSummaryForGithubBranch({ repoId, branchName });
}
async function emitPullRequestChangeEvents(c: any, beforeRows: any[], afterRows: any[]) {
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
const organization = await getOrCreateOrganization(c, c.state.organizationId);
const beforeById = new Map(beforeRows.map((row) => [row.prId, row]));
const afterById = new Map(afterRows.map((row) => [row.prId, row]));
@ -258,7 +282,7 @@ async function emitPullRequestChangeEvents(c: any, beforeRows: any[], afterRows:
if (!changed) {
continue;
}
await workspace.applyOpenPullRequestUpdate({
await organization.applyOpenPullRequestUpdate({
pullRequest: pullRequestSummaryFromRow(row),
});
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName);
@ -268,14 +292,14 @@ async function emitPullRequestChangeEvents(c: any, beforeRows: any[], afterRows:
if (afterById.has(prId)) {
continue;
}
await workspace.removeOpenPullRequest({ prId });
await organization.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({
const organization = await getOrCreateOrganization(c, c.state.organizationId);
const match = await organization.findTaskForGithubBranch({
repoId: row.repoId,
branchName: row.headRefName,
});
@ -283,7 +307,7 @@ async function autoArchiveTaskForClosedPullRequest(c: any, row: any) {
return;
}
try {
const task = getTask(c, c.state.workspaceId, row.repoId, match.taskId);
const task = getTask(c, c.state.organizationId, 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.
@ -391,6 +415,69 @@ async function resolvePullRequests(
}));
}
async function listRepositoryBranchesForContext(
context: Awaited<ReturnType<typeof getOrganizationContext>>,
repository: GithubRepositoryRecord,
): Promise<GithubBranchRecord[]> {
const { appShell } = getActorRuntimeContext();
let branches: Array<{ name: string; commitSha: string }> = [];
if (context.installationId != null) {
try {
branches = await appShell.github.listInstallationRepositoryBranches(context.installationId, repository.fullName);
} catch (error) {
if (!context.accessToken) {
throw error;
}
}
}
if (branches.length === 0 && context.accessToken) {
branches = await appShell.github.listUserRepositoryBranches(context.accessToken, repository.fullName);
}
const repoId = repoIdFromRemote(repository.cloneUrl);
return branches.map((branch) => ({
repoId,
branchName: branch.name,
commitSha: branch.commitSha,
}));
}
async function resolveBranches(
_c: any,
context: Awaited<ReturnType<typeof getOrganizationContext>>,
repositories: GithubRepositoryRecord[],
): Promise<GithubBranchRecord[]> {
return (await Promise.all(repositories.map((repository) => listRepositoryBranchesForContext(context, repository)))).flat();
}
async function refreshRepositoryBranches(
c: any,
context: Awaited<ReturnType<typeof getOrganizationContext>>,
repository: GithubRepositoryRecord,
updatedAt: number,
): Promise<void> {
const nextBranches = await listRepositoryBranchesForContext(context, repository);
await c.db
.delete(githubBranches)
.where(eq(githubBranches.repoId, repoIdFromRemote(repository.cloneUrl)))
.run();
for (const branch of nextBranches) {
await c.db
.insert(githubBranches)
.values({
branchId: `${branch.repoId}:${branch.branchName}`,
repoId: branch.repoId,
branchName: branch.branchName,
commitSha: branch.commitSha,
updatedAt,
})
.run();
}
}
async function readAllPullRequestRows(c: any) {
return await c.db.select().from(githubPullRequests).all();
}
@ -409,15 +496,17 @@ async function runFullSync(c: any, input: FullSyncInput = {}) {
});
const repositories = await resolveRepositories(c, context);
const branches = await resolveBranches(c, context, repositories);
const members = await resolveMembers(c, context);
const pullRequests = await resolvePullRequests(c, context, repositories);
await replaceRepositories(c, repositories, startedAt);
await replaceBranches(c, branches, startedAt);
await replaceMembers(c, members, startedAt);
await replacePullRequests(c, pullRequests);
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
await workspace.applyGithubDataProjection({
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyGithubDataProjection({
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
@ -455,16 +544,18 @@ export const githubData = actor({
actionTimeout: 5 * 60_000,
},
createState: (_c, input: GithubDataInput) => ({
workspaceId: input.workspaceId,
organizationId: input.organizationId,
}),
actions: {
async getSummary(c) {
const repositories = await c.db.select().from(githubRepositories).all();
const branches = await c.db.select().from(githubBranches).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,
branchCount: branches.length,
memberCount: members.length,
pullRequestCount: pullRequests.length,
};
@ -477,14 +568,39 @@ export const githubData = actor({
fullName: row.fullName,
cloneUrl: row.cloneUrl,
private: Boolean(row.private),
defaultBranch: row.defaultBranch,
}));
},
async getRepository(c, input: { repoId: string }) {
const row = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, input.repoId)).get();
if (!row) {
return null;
}
return {
repoId: row.repoId,
fullName: row.fullName,
cloneUrl: row.cloneUrl,
private: Boolean(row.private),
defaultBranch: row.defaultBranch,
};
},
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 listBranchesForRepository(c, input: { repoId: string }) {
const rows = await c.db.select().from(githubBranches).where(eq(githubBranches.repoId, input.repoId)).all();
return rows
.map((row) => ({
branchName: row.branchName,
commitSha: row.commitSha,
}))
.sort((left, right) => left.branchName.localeCompare(right.branchName));
},
async listOpenPullRequests(c) {
const rows = await c.db.select().from(githubPullRequests).all();
return rows.map(pullRequestSummaryFromRow).sort((left, right) => right.updatedAtMs - left.updatedAtMs);
@ -539,6 +655,7 @@ export const githubData = actor({
fullName: repository.fullName,
cloneUrl: repository.cloneUrl,
private: repository.private ? 1 : 0,
defaultBranch: repository.defaultBranch,
updatedAt,
})
.onConflictDoUpdate({
@ -547,13 +664,25 @@ export const githubData = actor({
fullName: repository.fullName,
cloneUrl: repository.cloneUrl,
private: repository.private ? 1 : 0,
defaultBranch: repository.defaultBranch,
updatedAt,
},
})
.run();
await refreshRepositoryBranches(
c,
context,
{
fullName: repository.fullName,
cloneUrl: repository.cloneUrl,
private: repository.private,
defaultBranch: repository.defaultBranch,
},
updatedAt,
);
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
await workspace.applyGithubRepositoryProjection({
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyGithubRepositoryProjection({
repoId: input.repoId,
remoteUrl: repository.cloneUrl,
});
@ -562,6 +691,7 @@ export const githubData = actor({
fullName: repository.fullName,
cloneUrl: repository.cloneUrl,
private: repository.private,
defaultBranch: repository.defaultBranch,
};
},
@ -656,6 +786,7 @@ export const githubData = actor({
async clearState(c, input: ClearStateInput) {
const beforeRows = await readAllPullRequestRows(c);
await c.db.delete(githubPullRequests).run();
await c.db.delete(githubBranches).run();
await c.db.delete(githubRepositories).run();
await c.db.delete(githubMembers).run();
await writeMeta(c, {
@ -667,8 +798,8 @@ export const githubData = actor({
lastSyncAt: null,
});
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
await workspace.applyGithubDataProjection({
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyGithubDataProjection({
connectedAccount: input.connectedAccount,
installationStatus: input.installationStatus,
installationId: input.installationId,
@ -683,6 +814,7 @@ export const githubData = actor({
async handlePullRequestWebhook(c, input: PullRequestWebhookInput) {
const beforeRows = await readAllPullRequestRows(c);
const repoId = repoIdFromRemote(input.repository.cloneUrl);
const currentRepository = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, repoId)).get();
const updatedAt = Date.now();
const state = normalizePrStatus(input.pullRequest);
const prId = `${repoId}#${input.pullRequest.number}`;
@ -694,6 +826,7 @@ export const githubData = actor({
fullName: input.repository.fullName,
cloneUrl: input.repository.cloneUrl,
private: input.repository.private ? 1 : 0,
defaultBranch: currentRepository?.defaultBranch ?? input.pullRequest.baseRefName ?? "main",
updatedAt,
})
.onConflictDoUpdate({
@ -702,6 +835,7 @@ export const githubData = actor({
fullName: input.repository.fullName,
cloneUrl: input.repository.cloneUrl,
private: input.repository.private ? 1 : 0,
defaultBranch: currentRepository?.defaultBranch ?? input.pullRequest.baseRefName ?? "main",
updatedAt,
},
})
@ -753,8 +887,8 @@ export const githubData = actor({
lastSyncAt: updatedAt,
});
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
await workspace.applyGithubRepositoryProjection({
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyGithubRepositoryProjection({
repoId,
remoteUrl: input.repository.cloneUrl,
});

View file

@ -1,12 +1,12 @@
import { authUserKey, githubDataKey, taskKey, historyKey, projectBranchSyncKey, projectKey, taskSandboxKey, workspaceKey } from "./keys.js";
import { authUserKey, githubDataKey, historyKey, organizationKey, repositoryKey, taskKey, taskSandboxKey } from "./keys.js";
export function actorClient(c: any) {
return c.client();
}
export async function getOrCreateWorkspace(c: any, workspaceId: string) {
return await actorClient(c).workspace.getOrCreate(workspaceKey(workspaceId), {
createWithInput: workspaceId,
export async function getOrCreateOrganization(c: any, organizationId: string) {
return await actorClient(c).organization.getOrCreate(organizationKey(organizationId), {
createWithInput: organizationId,
});
}
@ -20,76 +20,61 @@ export function getAuthUser(c: any, userId: string) {
return actorClient(c).authUser.get(authUserKey(userId));
}
export async function getOrCreateProject(c: any, workspaceId: string, repoId: string, remoteUrl: string) {
return await actorClient(c).project.getOrCreate(projectKey(workspaceId, repoId), {
export async function getOrCreateRepository(c: any, organizationId: string, repoId: string, remoteUrl: string) {
return await actorClient(c).repository.getOrCreate(repositoryKey(organizationId, repoId), {
createWithInput: {
workspaceId,
organizationId,
repoId,
remoteUrl,
},
});
}
export function getProject(c: any, workspaceId: string, repoId: string) {
return actorClient(c).project.get(projectKey(workspaceId, repoId));
export function getRepository(c: any, organizationId: string, repoId: string) {
return actorClient(c).repository.get(repositoryKey(organizationId, repoId));
}
export function getTask(c: any, workspaceId: string, repoId: string, taskId: string) {
return actorClient(c).task.get(taskKey(workspaceId, repoId, taskId));
export function getTask(c: any, organizationId: string, repoId: string, taskId: string) {
return actorClient(c).task.get(taskKey(organizationId, repoId, taskId));
}
export async function getOrCreateTask(c: any, workspaceId: string, repoId: string, taskId: string, createWithInput: Record<string, unknown>) {
return await actorClient(c).task.getOrCreate(taskKey(workspaceId, repoId, taskId), {
export async function getOrCreateTask(c: any, organizationId: string, repoId: string, taskId: string, createWithInput: Record<string, unknown>) {
return await actorClient(c).task.getOrCreate(taskKey(organizationId, repoId, taskId), {
createWithInput,
});
}
export async function getOrCreateHistory(c: any, workspaceId: string, repoId: string) {
return await actorClient(c).history.getOrCreate(historyKey(workspaceId, repoId), {
export async function getOrCreateHistory(c: any, organizationId: string, repoId: string) {
return await actorClient(c).history.getOrCreate(historyKey(organizationId, repoId), {
createWithInput: {
workspaceId,
organizationId,
repoId,
},
});
}
export async function getOrCreateGithubData(c: any, workspaceId: string) {
return await actorClient(c).githubData.getOrCreate(githubDataKey(workspaceId), {
export async function getOrCreateGithubData(c: any, organizationId: string) {
return await actorClient(c).githubData.getOrCreate(githubDataKey(organizationId), {
createWithInput: {
workspaceId,
organizationId,
},
});
}
export function getGithubData(c: any, workspaceId: string) {
return actorClient(c).githubData.get(githubDataKey(workspaceId));
export function getGithubData(c: any, organizationId: string) {
return actorClient(c).githubData.get(githubDataKey(organizationId));
}
export async function getOrCreateProjectBranchSync(c: any, workspaceId: string, repoId: string, repoPath: string, intervalMs: number) {
return await actorClient(c).projectBranchSync.getOrCreate(projectBranchSyncKey(workspaceId, repoId), {
createWithInput: {
workspaceId,
repoId,
repoPath,
intervalMs,
},
});
export function getTaskSandbox(c: any, organizationId: string, sandboxId: string) {
return actorClient(c).taskSandbox.get(taskSandboxKey(organizationId, sandboxId));
}
export function getTaskSandbox(c: any, workspaceId: string, sandboxId: string) {
return actorClient(c).taskSandbox.get(taskSandboxKey(workspaceId, sandboxId));
}
export async function getOrCreateTaskSandbox(c: any, workspaceId: string, sandboxId: string, createWithInput?: Record<string, unknown>) {
return await actorClient(c).taskSandbox.getOrCreate(taskSandboxKey(workspaceId, sandboxId), {
export async function getOrCreateTaskSandbox(c: any, organizationId: string, sandboxId: string, createWithInput?: Record<string, unknown>) {
return await actorClient(c).taskSandbox.getOrCreate(taskSandboxKey(organizationId, sandboxId), {
createWithInput,
});
}
export function selfProjectBranchSync(c: any) {
return actorClient(c).projectBranchSync.getForId(c.actorId);
}
export function selfHistory(c: any) {
return actorClient(c).history.getForId(c.actorId);
}
@ -98,12 +83,12 @@ export function selfTask(c: any) {
return actorClient(c).task.getForId(c.actorId);
}
export function selfWorkspace(c: any) {
return actorClient(c).workspace.getForId(c.actorId);
export function selfOrganization(c: any) {
return actorClient(c).organization.getForId(c.actorId);
}
export function selfProject(c: any) {
return actorClient(c).project.getForId(c.actorId);
export function selfRepository(c: any) {
return actorClient(c).repository.getForId(c.actorId);
}
export function selfAuthUser(c: any) {

View file

@ -8,7 +8,7 @@ import { historyDb } from "./db/db.js";
import { events } from "./db/schema.js";
export interface HistoryInput {
workspaceId: string;
organizationId: string;
repoId: string;
}
@ -70,7 +70,7 @@ export const history = actor({
icon: "database",
},
createState: (_c, input: HistoryInput) => ({
workspaceId: input.workspaceId,
organizationId: input.organizationId,
repoId: input.repoId,
}),
actions: {
@ -106,7 +106,7 @@ export const history = actor({
return rows.map((row) => ({
...row,
workspaceId: c.state.workspaceId,
organizationId: c.state.organizationId,
repoId: c.state.repoId,
}));
},

View file

@ -3,10 +3,9 @@ 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 { project } from "./project/index.js";
import { repository } from "./repository/index.js";
import { taskSandbox } from "./sandbox/index.js";
import { workspace } from "./workspace/index.js";
import { organization } from "./organization/index.js";
import { logger } from "../logging.js";
const RUNNER_VERSION = Math.floor(Date.now() / 1000);
@ -23,13 +22,12 @@ export const registry = setup({
},
use: {
authUser,
workspace,
project,
organization,
repository,
task,
taskSandbox,
history,
githubData,
projectBranchSync,
},
});
@ -40,7 +38,6 @@ 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/index.js";
export * from "./repository/index.js";
export * from "./sandbox/index.js";
export * from "./workspace/index.js";
export * from "./organization/index.js";

View file

@ -1,33 +1,29 @@
export type ActorKey = string[];
export function workspaceKey(workspaceId: string): ActorKey {
return ["ws", workspaceId];
export function organizationKey(organizationId: string): ActorKey {
return ["org", organizationId];
}
export function authUserKey(userId: string): ActorKey {
return ["ws", "app", "user", userId];
return ["org", "app", "user", userId];
}
export function projectKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId];
export function repositoryKey(organizationId: string, repoId: string): ActorKey {
return ["org", organizationId, "repository", repoId];
}
export function taskKey(workspaceId: string, repoId: string, taskId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "task", taskId];
export function taskKey(organizationId: string, repoId: string, taskId: string): ActorKey {
return ["org", organizationId, "repository", repoId, "task", taskId];
}
export function taskSandboxKey(workspaceId: string, sandboxId: string): ActorKey {
return ["ws", workspaceId, "sandbox", sandboxId];
export function taskSandboxKey(organizationId: string, sandboxId: string): ActorKey {
return ["org", organizationId, "sandbox", sandboxId];
}
export function historyKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "history"];
export function historyKey(organizationId: string, repoId: string): ActorKey {
return ["org", organizationId, "repository", repoId, "history"];
}
export function githubDataKey(workspaceId: string): ActorKey {
return ["ws", workspaceId, "github-data"];
}
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "branch-sync"];
export function githubDataKey(organizationId: string): ActorKey {
return ["org", organizationId, "github-data"];
}

View file

@ -2,7 +2,11 @@ import { logger } from "../logging.js";
export function resolveErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
let msg = error.message;
if (error.cause) {
msg += ` [cause: ${resolveErrorMessage(error.cause)}]`;
}
return msg;
}
return String(error);
}

View file

@ -1,18 +1,14 @@
// @ts-nocheck
import { setTimeout as delay } from "node:timers/promises";
import { desc, eq } from "drizzle-orm";
import { Loop } from "rivetkit/workflow";
import type {
AddRepoInput,
CreateTaskInput,
HistoryEvent,
HistoryQueryInput,
ListTasksInput,
ProviderId,
SandboxProviderId,
RepoOverview,
RepoRecord,
RepoStackActionInput,
RepoStackActionResult,
StarSandboxAgentRepoInput,
StarSandboxAgentRepoResult,
SwitchResult,
@ -26,37 +22,33 @@ import type {
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchTabInput,
TaskWorkbenchSessionInput,
TaskWorkbenchUpdateDraftInput,
WorkbenchOpenPrSummary,
WorkbenchRepoSummary,
WorkbenchRepositorySummary,
WorkbenchSessionSummary,
WorkbenchTaskSummary,
WorkspaceEvent,
WorkspaceSummarySnapshot,
WorkspaceUseInput,
OrganizationEvent,
OrganizationSummarySnapshot,
OrganizationUseInput,
} from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../context.js";
import { getGithubData, getOrCreateGithubData, getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
import { getGithubData, getOrCreateGithubData, getTask, getOrCreateHistory, getOrCreateRepository, selfOrganization } 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 { organizationProfile, taskLookup, repos, providerProfiles, taskSummaries } from "./db/schema.js";
import { defaultSandboxProviderId } from "../../sandbox-config.js";
import { repoIdFromRemote } from "../../services/repo.js";
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
import { organizationProfile, taskLookup, repos, taskSummaries } from "./db/schema.js";
import { agentTypeForModel } from "../task/workbench.js";
import { expectQueueResponse } from "../../services/queue.js";
import { workspaceAppActions } from "./app-shell.js";
import { organizationAppActions } from "./app-shell.js";
interface WorkspaceState {
workspaceId: string;
}
interface RefreshProviderProfilesCommand {
providerId?: ProviderId;
interface OrganizationState {
organizationId: string;
}
interface GetTaskInput {
workspaceId: string;
organizationId: string;
taskId: string;
}
@ -65,32 +57,30 @@ interface TaskProxyActionInput extends GetTaskInput {
}
interface RepoOverviewInput {
workspaceId: string;
organizationId: string;
repoId: string;
}
const WORKSPACE_QUEUE_NAMES = [
"workspace.command.addRepo",
"workspace.command.createTask",
"workspace.command.refreshProviderProfiles",
"workspace.command.syncGithubOrganizationRepos",
"workspace.command.syncGithubSession",
const ORGANIZATION_QUEUE_NAMES = [
"organization.command.createTask",
"organization.command.syncGithubOrganizationRepos",
"organization.command.syncGithubSession",
] as const;
const SANDBOX_AGENT_REPO = "rivet-dev/sandbox-agent";
type WorkspaceQueueName = (typeof WORKSPACE_QUEUE_NAMES)[number];
type OrganizationQueueName = (typeof ORGANIZATION_QUEUE_NAMES)[number];
export { WORKSPACE_QUEUE_NAMES };
export { ORGANIZATION_QUEUE_NAMES };
export function workspaceWorkflowQueueName(name: WorkspaceQueueName): WorkspaceQueueName {
export function organizationWorkflowQueueName(name: OrganizationQueueName): OrganizationQueueName {
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}`);
function assertOrganization(c: { state: OrganizationState }, organizationId: string): void {
if (organizationId !== c.state.organizationId) {
throw new Error(`Organization actor mismatch: actor=${c.state.organizationId} command=${organizationId}`);
}
}
@ -136,12 +126,12 @@ async function collectAllTaskSummaries(c: any): Promise<TaskSummary[]> {
const all: TaskSummary[] = [];
for (const row of repoRows) {
try {
const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl);
const snapshot = await project.listTaskSummaries({ includeArchived: true });
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl);
const snapshot = await repository.listTaskSummaries({ includeArchived: true });
all.push(...snapshot);
} catch (error) {
logActorWarning("workspace", "failed collecting tasks for repo", {
workspaceId: c.state.workspaceId,
logActorWarning("organization", "failed collecting tasks for repo", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
@ -166,7 +156,7 @@ function repoLabelFromRemote(remoteUrl: string): string {
return remoteUrl;
}
function buildRepoSummary(repoRow: { repoId: string; remoteUrl: string; updatedAt: number }, taskRows: WorkbenchTaskSummary[]): WorkbenchRepoSummary {
function buildRepoSummary(repoRow: { repoId: string; remoteUrl: string; updatedAt: number }, taskRows: WorkbenchTaskSummary[]): WorkbenchRepositorySummary {
const repoTasks = taskRows.filter((task) => task.repoId === repoRow.repoId);
const latestActivityMs = repoTasks.reduce((latest, task) => Math.max(latest, task.updatedAtMs), repoRow.updatedAt);
@ -207,14 +197,14 @@ function taskSummaryFromRow(row: any): WorkbenchTaskSummary {
}
async function listOpenPullRequestsSnapshot(c: any, taskRows: WorkbenchTaskSummary[]): Promise<WorkbenchOpenPrSummary[]> {
const githubData = getGithubData(c, c.state.workspaceId);
const githubData = getGithubData(c, c.state.organizationId);
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> {
async function reconcileWorkbenchProjection(c: any): Promise<OrganizationSummarySnapshot> {
const repoRows = await c.db
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })
.from(repos)
@ -224,12 +214,12 @@ async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySna
const taskRows: WorkbenchTaskSummary[] = [];
for (const row of repoRows) {
try {
const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl);
const summaries = await project.listTaskSummaries({ includeArchived: true });
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl);
const summaries = await repository.listTaskSummaries({ includeArchived: true });
for (const summary of summaries) {
try {
await upsertTaskLookupRow(c, summary.taskId, row.repoId);
const task = getTask(c, c.state.workspaceId, row.repoId, summary.taskId);
const task = getTask(c, c.state.organizationId, row.repoId, summary.taskId);
const taskSummary = await task.getTaskSummary({});
taskRows.push(taskSummary);
await c.db
@ -241,8 +231,8 @@ async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySna
})
.run();
} catch (error) {
logActorWarning("workspace", "failed collecting task summary during reconciliation", {
workspaceId: c.state.workspaceId,
logActorWarning("organization", "failed collecting task summary during reconciliation", {
organizationId: c.state.organizationId,
repoId: row.repoId,
taskId: summary.taskId,
error: resolveErrorMessage(error),
@ -250,8 +240,8 @@ async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySna
}
}
} catch (error) {
logActorWarning("workspace", "failed collecting repo during workbench reconciliation", {
workspaceId: c.state.workspaceId,
logActorWarning("organization", "failed collecting repo during workbench reconciliation", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
@ -260,7 +250,7 @@ async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySna
taskRows.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
return {
workspaceId: c.state.workspaceId,
organizationId: c.state.organizationId,
repos: repoRows.map((row) => buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
taskSummaries: taskRows,
openPullRequests: await listOpenPullRequestsSnapshot(c, taskRows),
@ -269,33 +259,15 @@ async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySna
async function requireWorkbenchTask(c: any, taskId: string) {
const repoId = await resolveRepoId(c, taskId);
return getTask(c, c.state.workspaceId, repoId, taskId);
}
async function waitForWorkbenchTaskReady(task: any, timeoutMs = 5 * 60_000): Promise<any> {
const startedAt = Date.now();
for (;;) {
const record = await task.get();
if (record?.branchName && record?.title) {
return record;
}
if (record?.status === "error") {
throw new Error("task initialization failed before the workbench session was ready");
}
if (Date.now() - startedAt > timeoutMs) {
throw new Error("timed out waiting for task initialization");
}
await delay(1_000);
}
return getTask(c, c.state.organizationId, repoId, taskId);
}
/**
* Reads the workspace sidebar snapshot from the workspace actor's local SQLite
* Reads the organization sidebar snapshot from the organization actor's local SQLite
* 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> {
async function getOrganizationSummarySnapshot(c: any): Promise<OrganizationSummarySnapshot> {
const repoRows = await c.db
.select({
repoId: repos.repoId,
@ -309,7 +281,7 @@ async function getWorkspaceSummarySnapshot(c: any): Promise<WorkspaceSummarySnap
const summaries = taskRows.map(taskSummaryFromRow);
return {
workspaceId: c.state.workspaceId,
organizationId: c.state.organizationId,
repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
taskSummaries: summaries,
openPullRequests: await listOpenPullRequestsSnapshot(c, summaries),
@ -323,61 +295,14 @@ async function broadcastRepoSummary(
): Promise<void> {
const matchingTaskRows = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, repoRow.repoId)).all();
const repo = buildRepoSummary(repoRow, matchingTaskRows.map(taskSummaryFromRow));
c.broadcast("workspaceUpdated", { type, repo } satisfies WorkspaceEvent);
}
async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord> {
assertWorkspace(c, input.workspaceId);
const remoteUrl = normalizeRemoteUrl(input.remoteUrl);
if (!remoteUrl) {
throw new Error("remoteUrl is required");
}
const { driver } = getActorRuntimeContext();
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
await driver.git.validateRemote(remoteUrl, { githubToken: auth?.githubToken ?? null });
const repoId = repoIdFromRemote(remoteUrl);
const now = Date.now();
const existing = await c.db.select({ repoId: repos.repoId }).from(repos).where(eq(repos.repoId, repoId)).get();
await c.db
.insert(repos)
.values({
repoId,
remoteUrl,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: repos.repoId,
set: {
remoteUrl,
updatedAt: now,
},
})
.run();
await broadcastRepoSummary(c, existing ? "repoUpdated" : "repoAdded", {
repoId,
remoteUrl,
updatedAt: now,
});
return {
workspaceId: c.state.workspaceId,
repoId,
remoteUrl,
createdAt: now,
updatedAt: now,
};
c.broadcast("organizationUpdated", { type, repo } satisfies OrganizationEvent);
}
async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskRecord> {
assertWorkspace(c, input.workspaceId);
assertOrganization(c, input.organizationId);
const { config } = getActorRuntimeContext();
const providerId = input.providerId ?? defaultSandboxProviderId(config);
const sandboxProviderId = input.sandboxProviderId ?? defaultSandboxProviderId(config);
const repoId = input.repoId;
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get();
@ -386,27 +311,11 @@ async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskR
}
const remoteUrl = repoRow.remoteUrl;
await c.db
.insert(providerProfiles)
.values({
providerId,
profileJson: JSON.stringify({ providerId }),
updatedAt: Date.now(),
})
.onConflictDoUpdate({
target: providerProfiles.providerId,
set: {
profileJson: JSON.stringify({ providerId }),
updatedAt: Date.now(),
},
})
.run();
const repository = await getOrCreateRepository(c, c.state.organizationId, repoId, remoteUrl);
const project = await getOrCreateProject(c, c.state.workspaceId, repoId, remoteUrl);
const created = await project.createTask({
const created = await repository.createTask({
task: input.task,
providerId,
sandboxProviderId,
agentType: input.agentType ?? null,
explicitTitle: input.explicitTitle ?? null,
explicitBranchName: input.explicitBranchName ?? null,
@ -426,13 +335,13 @@ async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskR
.run();
try {
const task = getTask(c, c.state.workspaceId, repoId, created.taskId);
await workspaceActions.applyTaskSummaryUpdate(c, {
const task = getTask(c, c.state.organizationId, repoId, created.taskId);
await organizationActions.applyTaskSummaryUpdate(c, {
taskSummary: await task.getTaskSummary({}),
});
} catch (error) {
logActorWarning("workspace", "failed seeding task summary after task creation", {
workspaceId: c.state.workspaceId,
logActorWarning("organization", "failed seeding task summary after task creation", {
organizationId: c.state.organizationId,
repoId,
taskId: created.taskId,
error: resolveErrorMessage(error),
@ -442,34 +351,10 @@ async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskR
return created;
}
async function refreshProviderProfilesMutation(c: any, command?: RefreshProviderProfilesCommand): Promise<void> {
const body = command ?? {};
const { config } = getActorRuntimeContext();
const providerIds: ProviderId[] = body.providerId ? [body.providerId] : availableSandboxProviderIds(config);
for (const providerId of providerIds) {
await c.db
.insert(providerProfiles)
.values({
providerId,
profileJson: JSON.stringify({ providerId }),
updatedAt: Date.now(),
})
.onConflictDoUpdate({
target: providerProfiles.providerId,
set: {
profileJson: JSON.stringify({ providerId }),
updatedAt: Date.now(),
},
})
.run();
}
}
export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
await ctx.loop("workspace-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-workspace-command", {
names: [...WORKSPACE_QUEUE_NAMES],
export async function runOrganizationWorkflow(ctx: any): Promise<void> {
await ctx.loop("organization-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-organization-command", {
names: [...ORGANIZATION_QUEUE_NAMES],
completable: true,
});
if (!msg) {
@ -477,19 +362,9 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
}
try {
if (msg.name === "workspace.command.addRepo") {
if (msg.name === "organization.command.createTask") {
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",
name: "organization-create-task",
timeout: 5 * 60_000,
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
});
@ -497,17 +372,9 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
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") {
if (msg.name === "organization.command.syncGithubSession") {
await loopCtx.step({
name: "workspace-sync-github-session",
name: "organization-sync-github-session",
timeout: 60_000,
run: async () => {
const { syncGithubOrganizations } = await import("./app-shell.js");
@ -518,9 +385,9 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
return Loop.continue(undefined);
}
if (msg.name === "workspace.command.syncGithubOrganizationRepos") {
if (msg.name === "organization.command.syncGithubOrganizationRepos") {
await loopCtx.step({
name: "workspace-sync-github-organization-repos",
name: "organization-sync-github-organization-repos",
timeout: 60_000,
run: async () => {
const { syncGithubOrganizationRepos } = await import("./app-shell.js");
@ -532,14 +399,12 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
}
} catch (error) {
const message = resolveErrorMessage(error);
logActorWarning("workspace", "workspace workflow command failed", {
workspaceId: loopCtx.state.workspaceId,
logActorWarning("organization", "organization workflow command failed", {
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,
logActorWarning("organization", "organization workflow failed completing error response", {
queueName: msg.name,
error: resolveErrorMessage(completeError),
});
@ -550,25 +415,15 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
});
}
export const workspaceActions = {
...workspaceAppActions,
async useWorkspace(c: any, input: WorkspaceUseInput): Promise<{ workspaceId: string }> {
assertWorkspace(c, input.workspaceId);
return { workspaceId: c.state.workspaceId };
export const organizationActions = {
...organizationAppActions,
async useOrganization(c: any, input: OrganizationUseInput): Promise<{ organizationId: string }> {
assertOrganization(c, input.organizationId);
return { organizationId: c.state.organizationId };
},
async addRepo(c: any, input: AddRepoInput): Promise<RepoRecord> {
const self = selfWorkspace(c);
return expectQueueResponse<RepoRecord>(
await self.send(workspaceWorkflowQueueName("workspace.command.addRepo"), input, {
wait: true,
timeout: 60_000,
}),
);
},
async listRepos(c: any, input: WorkspaceUseInput): Promise<RepoRecord[]> {
assertWorkspace(c, input.workspaceId);
async listRepos(c: any, input: OrganizationUseInput): Promise<RepoRecord[]> {
assertOrganization(c, input.organizationId);
const rows = await c.db
.select({
@ -582,7 +437,7 @@ export const workspaceActions = {
.all();
return rows.map((row) => ({
workspaceId: c.state.workspaceId,
organizationId: c.state.organizationId,
repoId: row.repoId,
remoteUrl: row.remoteUrl,
createdAt: row.createdAt,
@ -591,19 +446,22 @@ export const workspaceActions = {
},
async createTask(c: any, input: CreateTaskInput): Promise<TaskRecord> {
const self = selfWorkspace(c);
const self = selfOrganization(c);
return expectQueueResponse<TaskRecord>(
await self.send(workspaceWorkflowQueueName("workspace.command.createTask"), input, {
await self.send(organizationWorkflowQueueName("organization.command.createTask"), input, {
wait: true,
timeout: 5 * 60_000,
timeout: 10_000,
}),
);
},
async starSandboxAgentRepo(c: any, input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult> {
assertWorkspace(c, input.workspaceId);
assertOrganization(c, input.organizationId);
const { driver } = getActorRuntimeContext();
await driver.github.starRepository(SANDBOX_AGENT_REPO);
const auth = await resolveOrganizationGithubAuth(c, c.state.organizationId);
await driver.github.starRepository(SANDBOX_AGENT_REPO, {
githubToken: auth?.githubToken ?? null,
});
return {
repo: SANDBOX_AGENT_REPO,
starredAt: Date.now(),
@ -613,7 +471,7 @@ export const workspaceActions = {
/**
* Called by task actors when their summary-level state changes.
* This is the write path for the local materialized projection; clients read
* the projection via `getWorkspaceSummary`, but only task actors should push
* the projection via `getOrganizationSummary`, but only task actors should push
* rows into it.
*/
async applyTaskSummaryUpdate(c: any, input: { taskSummary: WorkbenchTaskSummary }): Promise<void> {
@ -625,12 +483,12 @@ export const workspaceActions = {
set: taskSummaryRowFromSummary(input.taskSummary),
})
.run();
c.broadcast("workspaceUpdated", { type: "taskSummaryUpdated", taskSummary: input.taskSummary } satisfies WorkspaceEvent);
c.broadcast("organizationUpdated", { type: "taskSummaryUpdated", taskSummary: input.taskSummary } satisfies OrganizationEvent);
},
async removeTaskSummary(c: any, input: { taskId: string }): Promise<void> {
await c.db.delete(taskSummaries).where(eq(taskSummaries.taskId, input.taskId)).run();
c.broadcast("workspaceUpdated", { type: "taskRemoved", taskId: input.taskId } satisfies WorkspaceEvent);
c.broadcast("organizationUpdated", { type: "taskRemoved", taskId: input.taskId } satisfies OrganizationEvent);
},
async findTaskForGithubBranch(c: any, input: { repoId: string; branchName: string }): Promise<{ taskId: string | null }> {
@ -645,13 +503,13 @@ export const workspaceActions = {
for (const summary of matches) {
try {
const task = getTask(c, c.state.workspaceId, input.repoId, summary.taskId);
await workspaceActions.applyTaskSummaryUpdate(c, {
const task = getTask(c, c.state.organizationId, input.repoId, summary.taskId);
await organizationActions.applyTaskSummaryUpdate(c, {
taskSummary: await task.getTaskSummary({}),
});
} catch (error) {
logActorWarning("workspace", "failed refreshing task summary for GitHub branch", {
workspaceId: c.state.workspaceId,
logActorWarning("organization", "failed refreshing task summary for GitHub branch", {
organizationId: c.state.organizationId,
repoId: input.repoId,
branchName: input.branchName,
taskId: summary.taskId,
@ -666,11 +524,11 @@ export const workspaceActions = {
if (summaries.some((summary) => summary.branch === input.pullRequest.headRefName)) {
return;
}
c.broadcast("workspaceUpdated", { type: "pullRequestUpdated", pullRequest: input.pullRequest } satisfies WorkspaceEvent);
c.broadcast("organizationUpdated", { type: "pullRequestUpdated", pullRequest: input.pullRequest } satisfies OrganizationEvent);
},
async removeOpenPullRequest(c: any, input: { prId: string }): Promise<void> {
c.broadcast("workspaceUpdated", { type: "pullRequestRemoved", prId: input.prId } satisfies WorkspaceEvent);
c.broadcast("organizationUpdated", { type: "pullRequestRemoved", prId: input.prId } satisfies OrganizationEvent);
},
async applyGithubRepositoryProjection(c: any, input: { repoId: string; remoteUrl: string }): Promise<void> {
@ -747,7 +605,7 @@ export const workspaceActions = {
continue;
}
await c.db.delete(repos).where(eq(repos.repoId, repo.repoId)).run();
c.broadcast("workspaceUpdated", { type: "repoRemoved", repoId: repo.repoId } satisfies WorkspaceEvent);
c.broadcast("organizationUpdated", { type: "repoRemoved", repoId: repo.repoId } satisfies OrganizationEvent);
}
const profile = await c.db
@ -775,13 +633,13 @@ export const workspaceActions = {
async recordGithubWebhookReceipt(
c: any,
input: {
workspaceId: string;
organizationId: string;
event: string;
action?: string | null;
receivedAt?: number;
},
): Promise<void> {
assertWorkspace(c, input.workspaceId);
assertOrganization(c, input.organizationId);
const profile = await c.db
.select({ id: organizationProfile.id })
@ -802,45 +660,38 @@ export const workspaceActions = {
.run();
},
async getWorkspaceSummary(c: any, input: WorkspaceUseInput): Promise<WorkspaceSummarySnapshot> {
assertWorkspace(c, input.workspaceId);
return await getWorkspaceSummarySnapshot(c);
async getOrganizationSummary(c: any, input: OrganizationUseInput): Promise<OrganizationSummarySnapshot> {
assertOrganization(c, input.organizationId);
return await getOrganizationSummarySnapshot(c);
},
async reconcileWorkbenchState(c: any, input: WorkspaceUseInput): Promise<WorkspaceSummarySnapshot> {
assertWorkspace(c, input.workspaceId);
async reconcileWorkbenchState(c: any, input: OrganizationUseInput): Promise<OrganizationSummarySnapshot> {
assertOrganization(c, input.organizationId);
return await reconcileWorkbenchProjection(c);
},
async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string; tabId?: string }> {
const created = await workspaceActions.createTask(c, {
workspaceId: c.state.workspaceId,
async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string; sessionId?: string }> {
// Step 1: Create the task record (wait: true — local state mutations only).
const created = await organizationActions.createTask(c, {
organizationId: c.state.organizationId,
repoId: input.repoId,
task: input.task,
...(input.title ? { explicitTitle: input.title } : {}),
...(input.onBranch ? { onBranch: input.onBranch } : input.branch ? { explicitBranchName: input.branch } : {}),
...(input.model ? { agentType: agentTypeForModel(input.model) } : {}),
});
// Step 2: Enqueue session creation + initial message (wait: false).
// The task workflow creates the session record and sends the message in
// the background. The client observes progress via push events on the
// task subscription topic.
const task = await requireWorkbenchTask(c, created.taskId);
await waitForWorkbenchTaskReady(task);
const session = await task.createWorkbenchSession({
taskId: created.taskId,
...(input.model ? { model: input.model } : {}),
});
await task.sendWorkbenchMessage({
taskId: created.taskId,
tabId: session.tabId,
await task.createWorkbenchSessionAndSend({
model: input.model,
text: input.task,
attachments: [],
waitForCompletion: true,
});
await task.getSessionDetail({
sessionId: session.tabId,
});
return {
taskId: created.taskId,
tabId: session.tabId,
};
return { taskId: created.taskId };
},
async markWorkbenchUnread(c: any, input: TaskWorkbenchSelectInput): Promise<void> {
@ -858,7 +709,7 @@ export const workspaceActions = {
await task.renameWorkbenchBranch(input);
},
async createWorkbenchSession(c: any, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> {
async createWorkbenchSession(c: any, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }> {
const task = await requireWorkbenchTask(c, input.taskId);
return await task.createWorkbenchSession({ ...(input.model ? { model: input.model } : {}) });
},
@ -888,12 +739,12 @@ export const workspaceActions = {
await task.sendWorkbenchMessage(input);
},
async stopWorkbenchSession(c: any, input: TaskWorkbenchTabInput): Promise<void> {
async stopWorkbenchSession(c: any, input: TaskWorkbenchSessionInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.stopWorkbenchSession(input);
},
async closeWorkbenchSession(c: any, input: TaskWorkbenchTabInput): Promise<void> {
async closeWorkbenchSession(c: any, input: TaskWorkbenchSessionInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.closeWorkbenchSession(input);
},
@ -909,23 +760,23 @@ export const workspaceActions = {
},
async reloadGithubOrganization(c: any): Promise<void> {
await getOrCreateGithubData(c, c.state.workspaceId).reloadOrganization({});
await getOrCreateGithubData(c, c.state.organizationId).reloadOrganization({});
},
async reloadGithubPullRequests(c: any): Promise<void> {
await getOrCreateGithubData(c, c.state.workspaceId).reloadAllPullRequests({});
await getOrCreateGithubData(c, c.state.organizationId).reloadAllPullRequests({});
},
async reloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
await getOrCreateGithubData(c, c.state.workspaceId).reloadRepository(input);
await getOrCreateGithubData(c, c.state.organizationId).reloadRepository(input);
},
async reloadGithubPullRequest(c: any, input: { repoId: string; prNumber: number }): Promise<void> {
await getOrCreateGithubData(c, c.state.workspaceId).reloadPullRequest(input);
await getOrCreateGithubData(c, c.state.organizationId).reloadPullRequest(input);
},
async listTasks(c: any, input: ListTasksInput): Promise<TaskSummary[]> {
assertWorkspace(c, input.workspaceId);
assertOrganization(c, input.organizationId);
if (input.repoId) {
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
@ -933,67 +784,41 @@ export const workspaceActions = {
throw new Error(`Unknown repo: ${input.repoId}`);
}
const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
return await project.listTaskSummaries({ includeArchived: true });
const repository = await getOrCreateRepository(c, c.state.organizationId, input.repoId, repoRow.remoteUrl);
return await repository.listTaskSummaries({ includeArchived: true });
}
return await collectAllTaskSummaries(c);
},
async getRepoOverview(c: any, input: RepoOverviewInput): Promise<RepoOverview> {
assertWorkspace(c, input.workspaceId);
assertOrganization(c, input.organizationId);
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${input.repoId}`);
}
const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
await project.ensure({ remoteUrl: repoRow.remoteUrl });
return await project.getRepoOverview({});
},
async runRepoStackAction(c: any, input: RepoStackActionInput): Promise<RepoStackActionResult> {
assertWorkspace(c, input.workspaceId);
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${input.repoId}`);
}
const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
await project.ensure({ remoteUrl: repoRow.remoteUrl });
return await project.runRepoStackAction({
action: input.action,
branchName: input.branchName,
parentBranch: input.parentBranch,
});
const repository = await getOrCreateRepository(c, c.state.organizationId, input.repoId, repoRow.remoteUrl);
return await repository.getRepoOverview({});
},
async switchTask(c: any, taskId: string): Promise<SwitchResult> {
const repoId = await resolveRepoId(c, taskId);
const h = getTask(c, c.state.workspaceId, repoId, taskId);
const h = getTask(c, c.state.organizationId, repoId, taskId);
const record = await h.get();
const switched = await h.switch();
return {
workspaceId: c.state.workspaceId,
organizationId: c.state.organizationId,
taskId,
providerId: record.providerId,
sandboxProviderId: record.sandboxProviderId,
switchTarget: switched.switchTarget,
};
},
async refreshProviderProfiles(c: any, command?: RefreshProviderProfilesCommand): Promise<void> {
const self = selfWorkspace(c);
await self.send(workspaceWorkflowQueueName("workspace.command.refreshProviderProfiles"), command ?? {}, {
wait: true,
timeout: 60_000,
});
},
async history(c: any, input: HistoryQueryInput): Promise<HistoryEvent[]> {
assertWorkspace(c, input.workspaceId);
assertOrganization(c, input.organizationId);
const limit = input.limit ?? 20;
const repoRows = await c.db.select({ repoId: repos.repoId }).from(repos).all();
@ -1002,7 +827,7 @@ export const workspaceActions = {
for (const row of repoRows) {
try {
const hist = await getOrCreateHistory(c, c.state.workspaceId, row.repoId);
const hist = await getOrCreateHistory(c, c.state.organizationId, row.repoId);
const items = await hist.list({
branch: input.branch,
taskId: input.taskId,
@ -1010,8 +835,8 @@ export const workspaceActions = {
});
allEvents.push(...items);
} catch (error) {
logActorWarning("workspace", "history lookup failed for repo", {
workspaceId: c.state.workspaceId,
logActorWarning("organization", "history lookup failed for repo", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
@ -1023,7 +848,7 @@ export const workspaceActions = {
},
async getTask(c: any, input: GetTaskInput): Promise<TaskRecord> {
assertWorkspace(c, input.workspaceId);
assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId);
@ -1032,49 +857,49 @@ export const workspaceActions = {
throw new Error(`Unknown repo: ${repoId}`);
}
const project = await getOrCreateProject(c, c.state.workspaceId, repoId, repoRow.remoteUrl);
return await project.getTaskEnriched({ taskId: input.taskId });
const repository = await getOrCreateRepository(c, c.state.organizationId, repoId, repoRow.remoteUrl);
return await repository.getTaskEnriched({ taskId: input.taskId });
},
async attachTask(c: any, input: TaskProxyActionInput): Promise<{ target: string; sessionId: string | null }> {
assertWorkspace(c, input.workspaceId);
assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.workspaceId, repoId, input.taskId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId);
return await h.attach({ reason: input.reason });
},
async pushTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.workspaceId, repoId, input.taskId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId);
await h.push({ reason: input.reason });
},
async syncTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.workspaceId, repoId, input.taskId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId);
await h.sync({ reason: input.reason });
},
async mergeTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.workspaceId, repoId, input.taskId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId);
await h.merge({ reason: input.reason });
},
async archiveTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.workspaceId, repoId, input.taskId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId);
await h.archive({ reason: input.reason });
},
async killTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.workspaceId, repoId, input.taskId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId);
await h.kill({ reason: input.reason });
},
};

View file

@ -2,4 +2,4 @@ import { db } from "rivetkit/db/drizzle";
import * as schema from "./schema.js";
import migrations from "./migrations.js";
export const projectDb = db({ schema, migrations });
export const organizationDb = db({ schema, migrations });

View file

@ -0,0 +1,6 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/organization/db/drizzle",
schema: "./src/actors/organization/db/schema.ts",
});

View file

@ -69,12 +69,6 @@ CREATE TABLE `organization_profile` (
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `provider_profiles` (
`provider_id` text PRIMARY KEY NOT NULL,
`profile_json` text NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `repos` (
`repo_id` text PRIMARY KEY NOT NULL,
`remote_url` text NOT NULL,

View file

@ -457,37 +457,6 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"provider_profiles": {
"name": "provider_profiles",
"columns": {
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"profile_json": {
"name": "profile_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repos": {
"name": "repos",
"columns": {

View file

@ -22,6 +22,12 @@ const journal = {
tag: "0002_task_summaries",
breakpoints: true,
},
{
idx: 3,
when: 1773810001000,
tag: "0003_drop_provider_profiles",
breakpoints: true,
},
],
} as const;
@ -99,12 +105,6 @@ CREATE TABLE \`organization_profile\` (
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE \`provider_profiles\` (
\`provider_id\` text PRIMARY KEY NOT NULL,
\`profile_json\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE \`repos\` (
\`repo_id\` text PRIMARY KEY NOT NULL,
\`remote_url\` text NOT NULL,
@ -170,6 +170,8 @@ CREATE TABLE IF NOT EXISTS \`auth_verification\` (
\`pull_request_json\` text,
\`sessions_summary_json\` text DEFAULT '[]' NOT NULL
);
`,
m0003: `DROP TABLE IF EXISTS \`provider_profiles\`;
`,
} as const,
};

View file

@ -1,12 +1,6 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
// SQLite is per workspace actor instance, so no workspaceId column needed.
export const providerProfiles = sqliteTable("provider_profiles", {
providerId: text("provider_id").notNull().primaryKey(),
// Structured by the provider profile snapshot returned by provider integrations.
profileJson: text("profile_json").notNull(),
updatedAt: integer("updated_at").notNull(),
});
// SQLite is per organization actor instance, so no organizationId column needed.
export const repos = sqliteTable("repos", {
repoId: text("repo_id").notNull().primaryKey(),
@ -23,7 +17,7 @@ export const taskLookup = sqliteTable("task_lookup", {
/**
* Materialized sidebar projection maintained by task actors.
* The source of truth still lives on each task actor; this table exists so
* workspace reads can stay local and avoid fan-out across child actors.
* organization reads can stay local and avoid fan-out across child actors.
*/
export const taskSummaries = sqliteTable("task_summaries", {
taskId: text("task_id").notNull().primaryKey(),

View file

@ -0,0 +1,19 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { organizationDb } from "./db/db.js";
import { runOrganizationWorkflow, ORGANIZATION_QUEUE_NAMES, organizationActions } from "./actions.js";
export const organization = actor({
db: organizationDb,
queues: Object.fromEntries(ORGANIZATION_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
name: "Organization",
icon: "compass",
actionTimeout: 5 * 60_000,
},
createState: (_c, organizationId: string) => ({
organizationId,
}),
actions: organizationActions,
run: workflow(runOrganizationWorkflow),
});

View file

@ -1,178 +0,0 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type { GitDriver } from "../../driver.js";
import { getActorRuntimeContext } from "../context.js";
import { getProject, selfProjectBranchSync } from "../handles.js";
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
import { parentLookupFromStack } from "../project/stack-model.js";
import { withRepoGitLock } from "../../services/repo-git-lock.js";
export interface ProjectBranchSyncInput {
workspaceId: string;
repoId: string;
repoPath: string;
intervalMs: number;
}
interface SetIntervalCommand {
intervalMs: number;
}
interface EnrichedBranchSnapshot {
branchName: string;
commitSha: string;
parentBranch: string | null;
trackedInStack: boolean;
diffStat: string | null;
hasUnpushed: boolean;
conflictsWithMain: boolean;
}
interface ProjectBranchSyncState extends PollingControlState {
workspaceId: string;
repoId: string;
repoPath: string;
}
const CONTROL = {
start: "project.branch_sync.control.start",
stop: "project.branch_sync.control.stop",
setInterval: "project.branch_sync.control.set_interval",
force: "project.branch_sync.control.force",
} as const;
async function enrichBranches(workspaceId: string, repoId: string, repoPath: string, git: GitDriver): Promise<EnrichedBranchSnapshot[]> {
return await withRepoGitLock(repoPath, async () => {
await git.fetch(repoPath);
const branches = await git.listRemoteBranches(repoPath);
const { driver } = getActorRuntimeContext();
const stackEntries = await driver.stack.listStack(repoPath).catch(() => []);
const parentByBranch = parentLookupFromStack(stackEntries);
const enriched: EnrichedBranchSnapshot[] = [];
const baseRef = await git.remoteDefaultBaseRef(repoPath);
const baseSha = await git.revParse(repoPath, baseRef).catch(() => "");
for (const branch of branches) {
let branchDiffStat: string | null = null;
let branchHasUnpushed = false;
let branchConflicts = false;
try {
branchDiffStat = await git.diffStatForBranch(repoPath, branch.branchName);
} catch (error) {
logActorWarning("project-branch-sync", "diffStatForBranch failed", {
workspaceId,
repoId,
branchName: branch.branchName,
error: resolveErrorMessage(error),
});
branchDiffStat = null;
}
try {
const headSha = await git.revParse(repoPath, `origin/${branch.branchName}`);
branchHasUnpushed = Boolean(baseSha && headSha && headSha !== baseSha);
} catch (error) {
logActorWarning("project-branch-sync", "revParse failed", {
workspaceId,
repoId,
branchName: branch.branchName,
error: resolveErrorMessage(error),
});
branchHasUnpushed = false;
}
try {
branchConflicts = await git.conflictsWithMain(repoPath, branch.branchName);
} catch (error) {
logActorWarning("project-branch-sync", "conflictsWithMain failed", {
workspaceId,
repoId,
branchName: branch.branchName,
error: resolveErrorMessage(error),
});
branchConflicts = false;
}
enriched.push({
branchName: branch.branchName,
commitSha: branch.commitSha,
parentBranch: parentByBranch.get(branch.branchName) ?? null,
trackedInStack: parentByBranch.has(branch.branchName),
diffStat: branchDiffStat,
hasUnpushed: branchHasUnpushed,
conflictsWithMain: branchConflicts,
});
}
return enriched;
});
}
async function pollBranches(c: { state: ProjectBranchSyncState }): Promise<void> {
const { driver } = getActorRuntimeContext();
const enrichedItems = await enrichBranches(c.state.workspaceId, c.state.repoId, c.state.repoPath, driver.git);
const parent = getProject(c, c.state.workspaceId, c.state.repoId);
await parent.applyBranchSyncResult({ items: enrichedItems, at: Date.now() });
}
export const projectBranchSync = actor({
queues: {
[CONTROL.start]: queue(),
[CONTROL.stop]: queue(),
[CONTROL.setInterval]: queue(),
[CONTROL.force]: queue(),
},
options: {
name: "Project Branch Sync",
icon: "code-branch",
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
noSleep: true,
},
createState: (_c, input: ProjectBranchSyncInput): ProjectBranchSyncState => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
repoPath: input.repoPath,
intervalMs: input.intervalMs,
running: true,
}),
actions: {
async start(c): Promise<void> {
const self = selfProjectBranchSync(c);
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
},
async stop(c): Promise<void> {
const self = selfProjectBranchSync(c);
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
},
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
const self = selfProjectBranchSync(c);
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
},
async force(c): Promise<void> {
const self = selfProjectBranchSync(c);
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
},
},
run: workflow(async (ctx) => {
await runWorkflowPollingLoop<ProjectBranchSyncState>(ctx, {
loopName: "project-branch-sync-loop",
control: CONTROL,
onPoll: async (loopCtx) => {
try {
await pollBranches(loopCtx);
} catch (error) {
logActorWarning("project-branch-sync", "poll failed", {
error: resolveErrorMessage(error),
stack: resolveErrorStack(error),
});
}
},
});
}),
});

File diff suppressed because it is too large Load diff

View file

@ -1,6 +0,0 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/project/db/drizzle",
schema: "./src/actors/project/db/schema.ts",
});

View file

@ -1,40 +0,0 @@
CREATE TABLE `branches` (
`branch_name` text PRIMARY KEY NOT NULL,
`commit_sha` text NOT NULL,
`parent_branch` text,
`tracked_in_stack` integer DEFAULT 0 NOT NULL,
`diff_stat` text,
`has_unpushed` integer DEFAULT 0 NOT NULL,
`conflicts_with_main` integer DEFAULT 0 NOT NULL,
`first_seen_at` integer,
`last_seen_at` integer,
`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,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `task_index` (
`task_id` text PRIMARY KEY NOT NULL,
`branch_name` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);

View file

@ -1,265 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "6ffd6acb-e737-46ee-a8fe-fcfddcdd6ea9",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"branches": {
"name": "branches",
"columns": {
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"commit_sha": {
"name": "commit_sha",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_branch": {
"name": "parent_branch",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tracked_in_stack": {
"name": "tracked_in_stack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"diff_stat": {
"name": "diff_stat",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"has_unpushed": {
"name": "has_unpushed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"conflicts_with_main": {
"name": "conflicts_with_main",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"first_seen_at": {
"name": "first_seen_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_seen_at": {
"name": "last_seen_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"pr_cache": {
"name": "pr_cache",
"columns": {
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"pr_number": {
"name": "pr_number",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pr_url": {
"name": "pr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pr_author": {
"name": "pr_author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_draft": {
"name": "is_draft",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"ci_status": {
"name": "ci_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"review_status": {
"name": "review_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reviewer": {
"name": "reviewer",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fetched_at": {
"name": "fetched_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repo_meta": {
"name": "repo_meta",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"remote_url": {
"name": "remote_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_index": {
"name": "task_index",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -1,46 +0,0 @@
// This file is generated by src/actors/_scripts/generate-actor-migrations.ts.
// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql).
// Do not hand-edit this file.
const journal = {
entries: [
{
idx: 0,
when: 1773376221848,
tag: "0000_useful_la_nuit",
breakpoints: true,
},
],
} as const;
export default {
journal,
migrations: {
m0000: `CREATE TABLE \`branches\` (
\`branch_name\` text PRIMARY KEY NOT NULL,
\`commit_sha\` text NOT NULL,
\`parent_branch\` text,
\`tracked_in_stack\` integer DEFAULT 0 NOT NULL,
\`diff_stat\` text,
\`has_unpushed\` integer DEFAULT 0 NOT NULL,
\`conflicts_with_main\` integer DEFAULT 0 NOT NULL,
\`first_seen_at\` integer,
\`last_seen_at\` integer,
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE \`repo_meta\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`remote_url\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE \`task_index\` (
\`task_id\` text PRIMARY KEY NOT NULL,
\`branch_name\` text,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
} as const,
};

View file

@ -1,41 +0,0 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
// SQLite is per project actor instance (workspaceId+repoId), so no workspaceId/repoId columns needed.
export const branches = sqliteTable("branches", {
branchName: text("branch_name").notNull().primaryKey(),
commitSha: text("commit_sha").notNull(),
parentBranch: text("parent_branch"),
trackedInStack: integer("tracked_in_stack").notNull().default(0),
diffStat: text("diff_stat"),
hasUnpushed: integer("has_unpushed").notNull().default(0),
conflictsWithMain: integer("conflicts_with_main").notNull().default(0),
firstSeenAt: integer("first_seen_at"),
lastSeenAt: integer("last_seen_at"),
updatedAt: integer("updated_at").notNull(),
});
export const repoMeta = sqliteTable("repo_meta", {
id: integer("id").primaryKey(),
remoteUrl: text("remote_url").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const taskIndex = sqliteTable("task_index", {
taskId: text("task_id").notNull().primaryKey(),
branchName: text("branch_name"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const repoActionJobs = sqliteTable("repo_action_jobs", {
jobId: text("job_id").notNull().primaryKey(),
action: text("action").notNull(),
branchName: text("branch_name"),
parentBranch: text("parent_branch"),
status: text("status").notNull(),
message: text("message").notNull(),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
completedAt: integer("completed_at"),
});

View file

@ -1,30 +0,0 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { projectDb } from "./db/db.js";
import { PROJECT_QUEUE_NAMES, projectActions, runProjectWorkflow } from "./actions.js";
export interface ProjectInput {
workspaceId: string;
repoId: string;
remoteUrl: string;
}
export const project = actor({
db: projectDb,
queues: Object.fromEntries(PROJECT_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
name: "Project",
icon: "folder",
actionTimeout: 5 * 60_000,
},
createState: (_c, input: ProjectInput) => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
remoteUrl: input.remoteUrl,
localPath: null as string | null,
syncActorsStarted: false,
taskIndexHydrated: false,
}),
actions: projectActions,
run: workflow(runProjectWorkflow),
});

View file

@ -1,69 +0,0 @@
export interface StackEntry {
branchName: string;
parentBranch: string | null;
}
export interface OrderedBranchRow {
branchName: string;
parentBranch: string | null;
updatedAt: number;
}
export function normalizeParentBranch(branchName: string, parentBranch: string | null | undefined): string | null {
const parent = parentBranch?.trim() || null;
if (!parent || parent === branchName) {
return null;
}
return parent;
}
export function parentLookupFromStack(entries: StackEntry[]): Map<string, string | null> {
const lookup = new Map<string, string | null>();
for (const entry of entries) {
const branchName = entry.branchName.trim();
if (!branchName) {
continue;
}
lookup.set(branchName, normalizeParentBranch(branchName, entry.parentBranch));
}
return lookup;
}
export function sortBranchesForOverview(rows: OrderedBranchRow[]): OrderedBranchRow[] {
const byName = new Map(rows.map((row) => [row.branchName, row]));
const depthMemo = new Map<string, number>();
const computing = new Set<string>();
const depthFor = (branchName: string): number => {
const cached = depthMemo.get(branchName);
if (cached != null) {
return cached;
}
if (computing.has(branchName)) {
return 999;
}
computing.add(branchName);
const row = byName.get(branchName);
const parent = row?.parentBranch;
let depth = 0;
if (parent && parent !== branchName && byName.has(parent)) {
depth = Math.min(998, depthFor(parent) + 1);
}
computing.delete(branchName);
depthMemo.set(branchName, depth);
return depth;
};
return [...rows].sort((a, b) => {
const da = depthFor(a.branchName);
const db = depthFor(b.branchName);
if (da !== db) {
return da - db;
}
if (a.updatedAt !== b.updatedAt) {
return b.updatedAt - a.updatedAt;
}
return a.branchName.localeCompare(b.branchName);
});
}

View file

@ -0,0 +1,557 @@
// @ts-nocheck
import { randomUUID } from "node:crypto";
import { and, desc, eq, isNotNull, ne } from "drizzle-orm";
import { Loop } from "rivetkit/workflow";
import type { AgentType, RepoOverview, SandboxProviderId, TaskRecord, TaskSummary } from "@sandbox-agent/foundry-shared";
import { getGithubData, getOrCreateHistory, getOrCreateTask, getTask, selfRepository } from "../handles.js";
import { deriveFallbackTitle, resolveCreateFlowDecision } from "../../services/create-flow.js";
import { expectQueueResponse } from "../../services/queue.js";
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
import { repoMeta, taskIndex } from "./db/schema.js";
interface CreateTaskCommand {
task: string;
sandboxProviderId: SandboxProviderId;
agentType: AgentType | null;
explicitTitle: string | null;
explicitBranchName: string | null;
initialPrompt: string | null;
onBranch: string | null;
}
interface RegisterTaskBranchCommand {
taskId: string;
branchName: string;
requireExistingRemote?: boolean;
}
interface ListTaskSummariesCommand {
includeArchived?: boolean;
}
interface GetTaskEnrichedCommand {
taskId: string;
}
interface GetPullRequestForBranchCommand {
branchName: string;
}
const REPOSITORY_QUEUE_NAMES = ["repository.command.createTask", "repository.command.registerTaskBranch"] as const;
type RepositoryQueueName = (typeof REPOSITORY_QUEUE_NAMES)[number];
export { REPOSITORY_QUEUE_NAMES };
export function repositoryWorkflowQueueName(name: RepositoryQueueName): RepositoryQueueName {
return name;
}
function isStaleTaskReferenceError(error: unknown): boolean {
const message = resolveErrorMessage(error);
return isActorNotFoundError(error) || message.startsWith("Task not found:");
}
async function persistRemoteUrl(c: any, remoteUrl: string): Promise<void> {
c.state.remoteUrl = remoteUrl;
await c.db
.insert(repoMeta)
.values({
id: 1,
remoteUrl,
updatedAt: Date.now(),
})
.onConflictDoUpdate({
target: repoMeta.id,
set: {
remoteUrl,
updatedAt: Date.now(),
},
})
.run();
}
async function deleteStaleTaskIndexRow(c: any, taskId: string): Promise<void> {
try {
await c.db.delete(taskIndex).where(eq(taskIndex.taskId, taskId)).run();
} catch {
// Best effort cleanup only.
}
}
async function reinsertTaskIndexRow(c: any, taskId: string, branchName: string | null, updatedAt: number): Promise<void> {
const now = Date.now();
await c.db
.insert(taskIndex)
.values({
taskId,
branchName,
createdAt: updatedAt || now,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskIndex.taskId,
set: {
branchName,
updatedAt: now,
},
})
.run();
}
async function listKnownTaskBranches(c: any): Promise<string[]> {
const rows = await c.db.select({ branchName: taskIndex.branchName }).from(taskIndex).where(isNotNull(taskIndex.branchName)).all();
return rows.map((row) => row.branchName).filter((value): value is string => typeof value === "string" && value.trim().length > 0);
}
async function resolveGitHubRepository(c: any) {
const githubData = getGithubData(c, c.state.organizationId);
return await githubData.getRepository({ repoId: c.state.repoId }).catch(() => null);
}
async function listGitHubBranches(c: any): Promise<Array<{ branchName: string; commitSha: string }>> {
const githubData = getGithubData(c, c.state.organizationId);
return await githubData.listBranchesForRepository({ repoId: c.state.repoId }).catch(() => []);
}
async function enrichTaskRecord(c: any, record: TaskRecord): Promise<TaskRecord> {
const branchName = record.branchName?.trim() || null;
if (!branchName) {
return record;
}
const pr =
branchName != null
? await getGithubData(c, c.state.organizationId)
.listPullRequestsForRepository({ repoId: c.state.repoId })
.then((rows: any[]) => rows.find((row) => row.headRefName === branchName) ?? null)
.catch(() => null)
: null;
return {
...record,
prUrl: pr?.url ?? null,
prAuthor: pr?.authorLogin ?? null,
ciStatus: null,
reviewStatus: null,
reviewer: pr?.authorLogin ?? null,
diffStat: record.diffStat ?? null,
hasUnpushed: record.hasUnpushed ?? null,
conflictsWithMain: record.conflictsWithMain ?? null,
parentBranch: record.parentBranch ?? null,
};
}
async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
const organizationId = c.state.organizationId;
const repoId = c.state.repoId;
const repoRemote = c.state.remoteUrl;
const onBranch = cmd.onBranch?.trim() || null;
const taskId = randomUUID();
let initialBranchName: string | null = null;
let initialTitle: string | null = null;
await persistRemoteUrl(c, repoRemote);
if (onBranch) {
initialBranchName = onBranch;
initialTitle = deriveFallbackTitle(cmd.task, cmd.explicitTitle ?? undefined);
await registerTaskBranchMutation(c, {
taskId,
branchName: onBranch,
requireExistingRemote: true,
});
} else {
const reservedBranches = await listKnownTaskBranches(c);
const resolved = resolveCreateFlowDecision({
task: cmd.task,
explicitTitle: cmd.explicitTitle ?? undefined,
explicitBranchName: cmd.explicitBranchName ?? undefined,
localBranches: [],
taskBranches: reservedBranches,
});
initialBranchName = resolved.branchName;
initialTitle = resolved.title;
const now = Date.now();
await c.db
.insert(taskIndex)
.values({
taskId,
branchName: resolved.branchName,
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing()
.run();
}
let taskHandle: Awaited<ReturnType<typeof getOrCreateTask>>;
try {
taskHandle = await getOrCreateTask(c, organizationId, repoId, taskId, {
organizationId,
repoId,
taskId,
repoRemote,
branchName: initialBranchName,
title: initialTitle,
task: cmd.task,
sandboxProviderId: cmd.sandboxProviderId,
agentType: cmd.agentType,
explicitTitle: null,
explicitBranchName: null,
initialPrompt: cmd.initialPrompt,
});
} catch (error) {
if (initialBranchName) {
await deleteStaleTaskIndexRow(c, taskId);
}
throw error;
}
const created = await taskHandle.initialize({ sandboxProviderId: cmd.sandboxProviderId });
const history = await getOrCreateHistory(c, organizationId, repoId);
await history.append({
kind: "task.created",
taskId,
payload: {
repoId,
sandboxProviderId: cmd.sandboxProviderId,
},
});
return created;
}
async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> {
const branchName = cmd.branchName.trim();
if (!branchName) {
throw new Error("branchName is required");
}
await persistRemoteUrl(c, c.state.remoteUrl);
const existingOwner = await c.db
.select({ taskId: taskIndex.taskId })
.from(taskIndex)
.where(and(eq(taskIndex.branchName, branchName), ne(taskIndex.taskId, cmd.taskId)))
.get();
if (existingOwner) {
let ownerMissing = false;
try {
await getTask(c, c.state.organizationId, c.state.repoId, existingOwner.taskId).get();
} catch (error) {
if (isStaleTaskReferenceError(error)) {
ownerMissing = true;
await deleteStaleTaskIndexRow(c, existingOwner.taskId);
} else {
throw error;
}
}
if (!ownerMissing) {
throw new Error(`branch is already assigned to a different task: ${branchName}`);
}
}
const branches = await listGitHubBranches(c);
const branchMatch = branches.find((branch) => branch.branchName === branchName) ?? null;
if (cmd.requireExistingRemote && !branchMatch) {
throw new Error(`Remote branch not found: ${branchName}`);
}
const repository = await resolveGitHubRepository(c);
const defaultBranch = repository?.defaultBranch ?? "main";
const headSha = branchMatch?.commitSha ?? branches.find((branch) => branch.branchName === defaultBranch)?.commitSha ?? "";
const now = Date.now();
await c.db
.insert(taskIndex)
.values({
taskId: cmd.taskId,
branchName,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskIndex.taskId,
set: {
branchName,
updatedAt: now,
},
})
.run();
return { branchName, headSha };
}
async function listTaskSummaries(c: any, includeArchived = false): Promise<TaskSummary[]> {
const taskRows = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).orderBy(desc(taskIndex.updatedAt)).all();
const records: TaskSummary[] = [];
for (const row of taskRows) {
try {
const record = await getTask(c, c.state.organizationId, c.state.repoId, row.taskId).get();
if (!includeArchived && record.status === "archived") {
continue;
}
records.push({
organizationId: record.organizationId,
repoId: record.repoId,
taskId: record.taskId,
branchName: record.branchName,
title: record.title,
status: record.status,
updatedAt: record.updatedAt,
});
} catch (error) {
if (isStaleTaskReferenceError(error)) {
await deleteStaleTaskIndexRow(c, row.taskId);
continue;
}
logActorWarning("repository", "failed loading task summary row", {
organizationId: c.state.organizationId,
repoId: c.state.repoId,
taskId: row.taskId,
error: resolveErrorMessage(error),
});
}
}
records.sort((a, b) => b.updatedAt - a.updatedAt);
return records;
}
function sortOverviewBranches(
branches: Array<{
branchName: string;
commitSha: string;
taskId: string | null;
taskTitle: string | null;
taskStatus: TaskRecord["status"] | null;
prNumber: number | null;
prState: string | null;
prUrl: string | null;
ciStatus: string | null;
reviewStatus: string | null;
reviewer: string | null;
updatedAt: number;
}>,
defaultBranch: string | null,
) {
return [...branches].sort((left, right) => {
if (defaultBranch) {
if (left.branchName === defaultBranch && right.branchName !== defaultBranch) return -1;
if (right.branchName === defaultBranch && left.branchName !== defaultBranch) return 1;
}
if (Boolean(left.taskId) !== Boolean(right.taskId)) {
return left.taskId ? -1 : 1;
}
if (left.updatedAt !== right.updatedAt) {
return right.updatedAt - left.updatedAt;
}
return left.branchName.localeCompare(right.branchName);
});
}
export async function runRepositoryWorkflow(ctx: any): Promise<void> {
await ctx.loop("repository-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-repository-command", {
names: [...REPOSITORY_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
try {
if (msg.name === "repository.command.createTask") {
const result = await loopCtx.step({
name: "repository-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 === "repository.command.registerTaskBranch") {
const result = await loopCtx.step({
name: "repository-register-task-branch",
timeout: 60_000,
run: async () => registerTaskBranchMutation(loopCtx, msg.body as RegisterTaskBranchCommand),
});
await msg.complete(result);
return Loop.continue(undefined);
}
} catch (error) {
const message = resolveErrorMessage(error);
logActorWarning("repository", "repository workflow command failed", {
queueName: msg.name,
error: message,
});
await msg.complete({ error: message }).catch(() => {});
}
return Loop.continue(undefined);
});
}
export const repositoryActions = {
async createTask(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
const self = selfRepository(c);
return expectQueueResponse<TaskRecord>(
await self.send(repositoryWorkflowQueueName("repository.command.createTask"), cmd, {
wait: true,
timeout: 10_000,
}),
);
},
async listReservedBranches(c: any): Promise<string[]> {
return await listKnownTaskBranches(c);
},
async registerTaskBranch(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> {
const self = selfRepository(c);
return expectQueueResponse<{ branchName: string; headSha: string }>(
await self.send(repositoryWorkflowQueueName("repository.command.registerTaskBranch"), cmd, {
wait: true,
timeout: 10_000,
}),
);
},
async listTaskSummaries(c: any, cmd?: ListTaskSummariesCommand): Promise<TaskSummary[]> {
return await listTaskSummaries(c, cmd?.includeArchived === true);
},
async getTaskEnriched(c: any, cmd: GetTaskEnrichedCommand): Promise<TaskRecord> {
const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.taskId, cmd.taskId)).get();
if (!row) {
const record = await getTask(c, c.state.organizationId, c.state.repoId, cmd.taskId).get();
await reinsertTaskIndexRow(c, cmd.taskId, record.branchName ?? null, record.updatedAt ?? Date.now());
return await enrichTaskRecord(c, record);
}
try {
const record = await getTask(c, c.state.organizationId, c.state.repoId, cmd.taskId).get();
return await enrichTaskRecord(c, record);
} catch (error) {
if (isStaleTaskReferenceError(error)) {
await deleteStaleTaskIndexRow(c, cmd.taskId);
throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`);
}
throw error;
}
},
async getRepositoryMetadata(c: any): Promise<{ defaultBranch: string | null; fullName: string | null; remoteUrl: string }> {
const repository = await resolveGitHubRepository(c);
return {
defaultBranch: repository?.defaultBranch ?? null,
fullName: repository?.fullName ?? null,
remoteUrl: c.state.remoteUrl,
};
},
async getRepoOverview(c: any): Promise<RepoOverview> {
await persistRemoteUrl(c, c.state.remoteUrl);
const now = Date.now();
const repository = await resolveGitHubRepository(c);
const githubBranches = await listGitHubBranches(c).catch(() => []);
const githubData = getGithubData(c, c.state.organizationId);
const prRows = await githubData.listPullRequestsForRepository({ repoId: c.state.repoId }).catch(() => []);
const prByBranch = new Map(prRows.map((row) => [row.headRefName, row]));
const taskRows = await c.db
.select({
taskId: taskIndex.taskId,
branchName: taskIndex.branchName,
updatedAt: taskIndex.updatedAt,
})
.from(taskIndex)
.all();
const taskMetaByBranch = new Map<string, { taskId: string; title: string | null; status: TaskRecord["status"] | null; updatedAt: number }>();
for (const row of taskRows) {
if (!row.branchName) {
continue;
}
try {
const record = await getTask(c, c.state.organizationId, c.state.repoId, row.taskId).get();
taskMetaByBranch.set(row.branchName, {
taskId: row.taskId,
title: record.title ?? null,
status: record.status,
updatedAt: record.updatedAt,
});
} catch (error) {
if (isStaleTaskReferenceError(error)) {
await deleteStaleTaskIndexRow(c, row.taskId);
continue;
}
}
}
const branchMap = new Map<string, { branchName: string; commitSha: string }>();
for (const branch of githubBranches) {
branchMap.set(branch.branchName, branch);
}
for (const branchName of taskMetaByBranch.keys()) {
if (!branchMap.has(branchName)) {
branchMap.set(branchName, { branchName, commitSha: "" });
}
}
if (repository?.defaultBranch && !branchMap.has(repository.defaultBranch)) {
branchMap.set(repository.defaultBranch, { branchName: repository.defaultBranch, commitSha: "" });
}
const branches = sortOverviewBranches(
[...branchMap.values()].map((branch) => {
const taskMeta = taskMetaByBranch.get(branch.branchName);
const pr = prByBranch.get(branch.branchName);
return {
branchName: branch.branchName,
commitSha: branch.commitSha,
taskId: taskMeta?.taskId ?? null,
taskTitle: taskMeta?.title ?? null,
taskStatus: taskMeta?.status ?? null,
prNumber: pr?.number ?? null,
prState: pr?.state ?? null,
prUrl: pr?.url ?? null,
ciStatus: null,
reviewStatus: null,
reviewer: pr?.authorLogin ?? null,
updatedAt: Math.max(taskMeta?.updatedAt ?? 0, pr?.updatedAtMs ?? 0, now),
};
}),
repository?.defaultBranch ?? null,
);
return {
organizationId: c.state.organizationId,
repoId: c.state.repoId,
remoteUrl: c.state.remoteUrl,
baseRef: repository?.defaultBranch ?? null,
fetchedAt: now,
branches,
};
},
async getPullRequestForBranch(c: any, cmd: GetPullRequestForBranchCommand): Promise<{ number: number; status: "draft" | "ready" } | null> {
const branchName = cmd.branchName?.trim();
if (!branchName) {
return null;
}
const githubData = getGithubData(c, c.state.organizationId);
return await githubData.getPullRequestForBranch({
repoId: c.state.repoId,
branchName,
});
},
};

View file

@ -2,4 +2,4 @@ import { db } from "rivetkit/db/drizzle";
import * as schema from "./schema.js";
import migrations from "./migrations.js";
export const workspaceDb = db({ schema, migrations });
export const repositoryDb = db({ schema, migrations });

View file

@ -0,0 +1,6 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/repository/db/drizzle",
schema: "./src/actors/repository/db/schema.ts",
});

View file

@ -0,0 +1,12 @@
CREATE TABLE `repo_meta` (
`id` integer PRIMARY KEY NOT NULL,
`remote_url` text NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `task_index` (
`task_id` text PRIMARY KEY NOT NULL,
`branch_name` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);

View file

@ -0,0 +1,87 @@
{
"version": "6",
"dialect": "sqlite",
"id": "6ffd6acb-e737-46ee-a8fe-fcfddcdd6ea9",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"repo_meta": {
"name": "repo_meta",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"remote_url": {
"name": "remote_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_index": {
"name": "task_index",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,43 @@
// This file is generated by src/actors/_scripts/generate-actor-migrations.ts.
// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql).
// Do not hand-edit this file.
const journal = {
entries: [
{
idx: 0,
when: 1773376221848,
tag: "0000_useful_la_nuit",
breakpoints: true,
},
{
idx: 1,
when: 1778900000000,
tag: "0001_remove_local_git_state",
breakpoints: true,
},
],
} as const;
export default {
journal,
migrations: {
m0000: `CREATE TABLE \`repo_meta\` (
\t\`id\` integer PRIMARY KEY NOT NULL,
\t\`remote_url\` text NOT NULL,
\t\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE \`task_index\` (
\t\`task_id\` text PRIMARY KEY NOT NULL,
\t\`branch_name\` text,
\t\`created_at\` integer NOT NULL,
\t\`updated_at\` integer NOT NULL
);
`,
m0001: `DROP TABLE IF EXISTS \`branches\`;
--> statement-breakpoint
DROP TABLE IF EXISTS \`repo_action_jobs\`;
`,
} as const,
};

View file

@ -0,0 +1,16 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
// SQLite is per repository actor instance (organizationId+repoId).
export const repoMeta = sqliteTable("repo_meta", {
id: integer("id").primaryKey(),
remoteUrl: text("remote_url").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const taskIndex = sqliteTable("task_index", {
taskId: text("task_id").notNull().primaryKey(),
branchName: text("branch_name"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});

View file

@ -0,0 +1,27 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { repositoryDb } from "./db/db.js";
import { REPOSITORY_QUEUE_NAMES, repositoryActions, runRepositoryWorkflow } from "./actions.js";
export interface RepositoryInput {
organizationId: string;
repoId: string;
remoteUrl: string;
}
export const repository = actor({
db: repositoryDb,
queues: Object.fromEntries(REPOSITORY_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
name: "Repository",
icon: "folder",
actionTimeout: 5 * 60_000,
},
createState: (_c, input: RepositoryInput) => ({
organizationId: input.organizationId,
repoId: input.repoId,
remoteUrl: input.remoteUrl,
}),
actions: repositoryActions,
run: workflow(runRepositoryWorkflow),
});

View file

@ -4,21 +4,21 @@ import { existsSync } from "node:fs";
import Dockerode from "dockerode";
import { SandboxAgent } from "sandbox-agent";
import { getActorRuntimeContext } from "../context.js";
import { workspaceKey } from "../keys.js";
import { organizationKey } from "../keys.js";
import { resolveSandboxProviderId } from "../../sandbox-config.js";
const SANDBOX_REPO_CWD = "/home/sandbox/workspace/repo";
const SANDBOX_REPO_CWD = "/home/sandbox/organization/repo";
const DEFAULT_LOCAL_SANDBOX_IMAGE = "rivetdev/sandbox-agent:full";
const DEFAULT_LOCAL_SANDBOX_PORT = 2468;
const dockerClient = new Dockerode({ socketPath: "/var/run/docker.sock" });
function parseTaskSandboxKey(key: readonly string[]): { workspaceId: string; taskId: string } {
if (key.length !== 4 || key[0] !== "ws" || key[2] !== "sandbox") {
function parseTaskSandboxKey(key: readonly string[]): { organizationId: string; taskId: string } {
if (key.length !== 4 || key[0] !== "org" || key[2] !== "sandbox") {
throw new Error(`Invalid task sandbox key: ${JSON.stringify(key)}`);
}
return {
workspaceId: key[1]!,
organizationId: key[1]!,
taskId: key[3]!,
};
}
@ -191,24 +191,24 @@ function sanitizeActorResult(value: unknown, seen = new WeakSet<object>()): unkn
const baseTaskSandbox = sandboxActor({
createProvider: async (c) => {
const { config } = getActorRuntimeContext();
const { workspaceId, taskId } = parseTaskSandboxKey(c.key);
const workspace = await c.client().workspace.getOrCreate(workspaceKey(workspaceId), {
createWithInput: workspaceId,
const { organizationId, taskId } = parseTaskSandboxKey(c.key);
const organization = await c.client().organization.getOrCreate(organizationKey(organizationId), {
createWithInput: organizationId,
});
const task = await workspace.getTask({ workspaceId, taskId });
const providerId = resolveSandboxProviderId(config, task.providerId);
const task = await organization.getTask({ organizationId, taskId });
const sandboxProviderId = resolveSandboxProviderId(config, task.sandboxProviderId);
if (providerId === "e2b") {
if (sandboxProviderId === "e2b") {
return e2b({
create: () => ({
template: config.providers.e2b.template ?? "sandbox-agent-full-0.3.x",
template: config.sandboxProviders.e2b.template ?? "sandbox-agent-full-0.3.x",
envs: sandboxEnvObject(),
}),
installAgents: ["claude", "codex"],
});
}
return createLocalSandboxProvider(config.providers.local.image ?? process.env.HF_LOCAL_SANDBOX_IMAGE ?? DEFAULT_LOCAL_SANDBOX_IMAGE);
return createLocalSandboxProvider(config.sandboxProviders.local.image ?? process.env.HF_LOCAL_SANDBOX_IMAGE ?? DEFAULT_LOCAL_SANDBOX_IMAGE);
},
});
@ -236,23 +236,23 @@ async function providerForConnection(c: any): Promise<any | null> {
const providerFactory = baseTaskSandbox.config.actions as Record<string, unknown>;
void providerFactory;
const { config } = getActorRuntimeContext();
const { workspaceId, taskId } = parseTaskSandboxKey(c.key);
const workspace = await c.client().workspace.getOrCreate(workspaceKey(workspaceId), {
createWithInput: workspaceId,
const { organizationId, taskId } = parseTaskSandboxKey(c.key);
const organization = await c.client().organization.getOrCreate(organizationKey(organizationId), {
createWithInput: organizationId,
});
const task = await workspace.getTask({ workspaceId, taskId });
const providerId = resolveSandboxProviderId(config, task.providerId);
const task = await organization.getTask({ organizationId, taskId });
const sandboxProviderId = resolveSandboxProviderId(config, task.sandboxProviderId);
const provider =
providerId === "e2b"
sandboxProviderId === "e2b"
? e2b({
create: () => ({
template: config.providers.e2b.template ?? "sandbox-agent-full-0.3.x",
template: config.sandboxProviders.e2b.template ?? "sandbox-agent-full-0.3.x",
envs: sandboxEnvObject(),
}),
installAgents: ["claude", "codex"],
})
: createLocalSandboxProvider(config.providers.local.image ?? process.env.HF_LOCAL_SANDBOX_IMAGE ?? DEFAULT_LOCAL_SANDBOX_IMAGE);
: createLocalSandboxProvider(config.sandboxProviders.local.image ?? process.env.HF_LOCAL_SANDBOX_IMAGE ?? DEFAULT_LOCAL_SANDBOX_IMAGE);
c.vars.provider = provider;
return provider;
@ -360,31 +360,31 @@ export const taskSandbox = actor({
}
},
async providerState(c: any): Promise<{ providerId: "e2b" | "local"; sandboxId: string; state: string; at: number }> {
async providerState(c: any): Promise<{ sandboxProviderId: "e2b" | "local"; sandboxId: string; state: string; at: number }> {
const { config } = getActorRuntimeContext();
const { taskId } = parseTaskSandboxKey(c.key);
const at = Date.now();
const providerId = resolveSandboxProviderId(config, c.state.providerName === "e2b" ? "e2b" : c.state.providerName === "docker" ? "local" : null);
const sandboxProviderId = resolveSandboxProviderId(config, c.state.providerName === "e2b" ? "e2b" : c.state.providerName === "docker" ? "local" : null);
if (c.state.sandboxDestroyed) {
return { providerId, sandboxId: taskId, state: "destroyed", at };
return { sandboxProviderId, sandboxId: taskId, state: "destroyed", at };
}
if (!c.state.sandboxId) {
return { providerId, sandboxId: taskId, state: "pending", at };
return { sandboxProviderId, sandboxId: taskId, state: "pending", at };
}
try {
const health = await baseActions.getHealth(c);
return {
providerId,
sandboxProviderId,
sandboxId: taskId,
state: health.status === "ok" ? "running" : "degraded",
at,
};
} catch {
return {
providerId,
sandboxProviderId,
sandboxId: taskId,
state: "error",
at,

View file

@ -10,6 +10,12 @@ const journal = {
tag: "0000_charming_maestro",
breakpoints: true,
},
{
idx: 1,
when: 1773810000000,
tag: "0001_sandbox_provider_columns",
breakpoints: true,
},
],
} as const;
@ -63,9 +69,13 @@ CREATE TABLE \`task_workbench_sessions\` (
\`created\` integer DEFAULT 1 NOT NULL,
\`closed\` integer DEFAULT 0 NOT NULL,
\`thinking_since_ms\` integer,
\`created_at\` integer NOT NULL,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0001: `ALTER TABLE \`task\` RENAME COLUMN \`provider_id\` TO \`sandbox_provider_id\`;
--> statement-breakpoint
ALTER TABLE \`task_sandboxes\` RENAME COLUMN \`provider_id\` TO \`sandbox_provider_id\`;
`,
} as const,
};

View file

@ -9,7 +9,7 @@ export const task = sqliteTable(
branchName: text("branch_name"),
title: text("title"),
task: text("task").notNull(),
providerId: text("provider_id").notNull(),
sandboxProviderId: text("sandbox_provider_id").notNull(),
status: text("status").notNull(),
agentType: text("agent_type").default("claude"),
prSubmitted: integer("pr_submitted").default(0),
@ -39,7 +39,7 @@ export const taskRuntime = sqliteTable(
export const taskSandboxes = sqliteTable("task_sandboxes", {
sandboxId: text("sandbox_id").notNull().primaryKey(),
providerId: text("provider_id").notNull(),
sandboxProviderId: text("sandbox_provider_id").notNull(),
sandboxActorId: text("sandbox_actor_id"),
switchTarget: text("switch_target").notNull(),
cwd: text("cwd"),

View file

@ -9,7 +9,7 @@ import type {
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchUpdateDraftInput,
ProviderId,
SandboxProviderId,
} from "@sandbox-agent/foundry-shared";
import { expectQueueResponse } from "../../services/queue.js";
import { selfTask } from "../handles.js";
@ -37,15 +37,14 @@ import {
import { TASK_QUEUE_NAMES, taskWorkflowQueueName, runTaskWorkflow } from "./workflow/index.js";
export interface TaskInput {
workspaceId: string;
organizationId: string;
repoId: string;
taskId: string;
repoRemote: string;
repoLocalPath?: string;
branchName: string | null;
title: string | null;
task: string;
providerId: ProviderId;
sandboxProviderId: SandboxProviderId;
agentType: AgentType | null;
explicitTitle: string | null;
explicitBranchName: string | null;
@ -53,15 +52,15 @@ export interface TaskInput {
}
interface InitializeCommand {
providerId?: ProviderId;
sandboxProviderId?: SandboxProviderId;
}
interface TaskActionCommand {
reason?: string;
}
interface TaskTabCommand {
tabId: string;
interface TaskSessionCommand {
sessionId: string;
}
interface TaskStatusSyncCommand {
@ -101,14 +100,15 @@ interface TaskWorkbenchSendMessageCommand {
attachments: Array<any>;
}
interface TaskWorkbenchSendMessageActionInput extends TaskWorkbenchSendMessageInput {
waitForCompletion?: boolean;
}
interface TaskWorkbenchCreateSessionCommand {
model?: string;
}
interface TaskWorkbenchCreateSessionAndSendCommand {
model?: string;
text: string;
}
interface TaskWorkbenchSessionCommand {
sessionId: string;
}
@ -122,15 +122,14 @@ export const task = actor({
actionTimeout: 5 * 60_000,
},
createState: (_c, input: TaskInput) => ({
workspaceId: input.workspaceId,
organizationId: input.organizationId,
repoId: input.repoId,
taskId: input.taskId,
repoRemote: input.repoRemote,
repoLocalPath: input.repoLocalPath,
branchName: input.branchName,
title: input.title,
task: input.task,
providerId: input.providerId,
sandboxProviderId: input.sandboxProviderId,
agentType: input.agentType,
explicitTitle: input.explicitTitle,
explicitBranchName: input.explicitBranchName,
@ -143,7 +142,7 @@ export const task = actor({
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.initialize"), cmd ?? {}, {
wait: true,
timeout: 5 * 60_000,
timeout: 10_000,
});
return expectQueueResponse<TaskRecord>(result);
},
@ -160,7 +159,7 @@ export const task = actor({
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.attach"), cmd ?? {}, {
wait: true,
timeout: 20_000,
timeout: 10_000,
});
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
},
@ -172,7 +171,7 @@ export const task = actor({
{},
{
wait: true,
timeout: 20_000,
timeout: 10_000,
},
);
return expectQueueResponse<{ switchTarget: string }>(result);
@ -236,7 +235,7 @@ export const task = actor({
{},
{
wait: true,
timeout: 20_000,
timeout: 10_000,
},
);
},
@ -256,27 +255,40 @@ export const task = actor({
});
},
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> {
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ sessionId: string }> {
const self = selfTask(c);
const result = await self.send(
taskWorkflowQueueName("task.command.workbench.create_session"),
{ ...(input?.model ? { model: input.model } : {}) } satisfies TaskWorkbenchCreateSessionCommand,
{
wait: true,
timeout: 5 * 60_000,
timeout: 10_000,
},
);
return expectQueueResponse<{ tabId: string }>(result);
return expectQueueResponse<{ sessionId: string }>(result);
},
/**
* Fire-and-forget: creates a workbench session and sends the initial message.
* Used by createWorkbenchTask so the caller doesn't block on session creation.
*/
async createWorkbenchSessionAndSend(c, input: { model?: string; text: string }): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.create_session_and_send"),
{ model: input.model, text: input.text } satisfies TaskWorkbenchCreateSessionAndSendCommand,
{ wait: false },
);
},
async renameWorkbenchSession(c, input: TaskWorkbenchRenameSessionInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.rename_session"),
{ sessionId: input.tabId, title: input.title } satisfies TaskWorkbenchSessionTitleCommand,
{ sessionId: input.sessionId, title: input.title } satisfies TaskWorkbenchSessionTitleCommand,
{
wait: true,
timeout: 20_000,
timeout: 10_000,
},
);
},
@ -285,10 +297,10 @@ export const task = actor({
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.set_session_unread"),
{ sessionId: input.tabId, unread: input.unread } satisfies TaskWorkbenchSessionUnreadCommand,
{ sessionId: input.sessionId, unread: input.unread } satisfies TaskWorkbenchSessionUnreadCommand,
{
wait: true,
timeout: 20_000,
timeout: 10_000,
},
);
},
@ -298,13 +310,12 @@ export const task = actor({
await self.send(
taskWorkflowQueueName("task.command.workbench.update_draft"),
{
sessionId: input.tabId,
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
} satisfies TaskWorkbenchUpdateDraftCommand,
{
wait: true,
timeout: 20_000,
wait: false,
},
);
},
@ -313,36 +324,32 @@ export const task = actor({
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.change_model"),
{ sessionId: input.tabId, model: input.model } satisfies TaskWorkbenchChangeModelCommand,
{ sessionId: input.sessionId, model: input.model } satisfies TaskWorkbenchChangeModelCommand,
{
wait: true,
timeout: 20_000,
timeout: 10_000,
},
);
},
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageActionInput): Promise<void> {
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageInput): Promise<void> {
const self = selfTask(c);
const result = await self.send(
await self.send(
taskWorkflowQueueName("task.command.workbench.send_message"),
{
sessionId: input.tabId,
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
} satisfies TaskWorkbenchSendMessageCommand,
{
wait: input.waitForCompletion === true,
...(input.waitForCompletion === true ? { timeout: 10 * 60_000 } : {}),
wait: false,
},
);
if (input.waitForCompletion === true) {
expectQueueResponse(result);
}
},
async stopWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
async stopWorkbenchSession(c, input: TaskSessionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, {
await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.sessionId } satisfies TaskWorkbenchSessionCommand, {
wait: false,
});
},
@ -355,9 +362,9 @@ export const task = actor({
});
},
async closeWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
async closeWorkbenchSession(c, input: TaskSessionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, {
await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.sessionId } satisfies TaskWorkbenchSessionCommand, {
wait: false,
});
},

View file

@ -3,10 +3,11 @@ import { randomUUID } from "node:crypto";
import { basename, dirname } from "node:path";
import { asc, eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateProject, getOrCreateTaskSandbox, getOrCreateWorkspace, getTaskSandbox, selfTask } from "../handles.js";
import { getOrCreateRepository, getOrCreateTaskSandbox, getOrCreateOrganization, getTaskSandbox, selfTask } from "../handles.js";
import { SANDBOX_REPO_CWD } from "../sandbox/index.js";
import { resolveSandboxProviderId } from "../../sandbox-config.js";
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
import { githubRepoFullNameFromRemote } from "../../services/repo.js";
import { task as taskTable, taskRuntime, taskSandboxes, taskWorkbenchSessions } from "./db/schema.js";
import { getCurrentRecord } from "./workflow/common.js";
@ -172,8 +173,7 @@ async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }
const mapped = rows.map((row: any) => ({
...row,
id: row.sessionId,
sessionId: row.sandboxSessionId ?? null,
tabId: row.sessionId,
sessionId: row.sessionId,
sandboxSessionId: row.sandboxSessionId ?? null,
status: row.status ?? "ready",
errorMessage: row.errorMessage ?? null,
@ -209,8 +209,7 @@ async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
return {
...row,
id: row.sessionId,
sessionId: row.sandboxSessionId ?? null,
tabId: row.sessionId,
sessionId: row.sessionId,
sandboxSessionId: row.sandboxSessionId ?? null,
status: row.status ?? "ready",
errorMessage: row.errorMessage ?? null,
@ -227,7 +226,7 @@ async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
async function ensureSessionMeta(
c: any,
params: {
tabId: string;
sessionId: string;
sandboxSessionId?: string | null;
model?: string;
sessionName?: string;
@ -238,7 +237,7 @@ async function ensureSessionMeta(
},
): Promise<any> {
await ensureWorkbenchSessionTable(c);
const existing = await readSessionMeta(c, params.tabId);
const existing = await readSessionMeta(c, params.sessionId);
if (existing) {
return existing;
}
@ -251,7 +250,7 @@ async function ensureSessionMeta(
await c.db
.insert(taskWorkbenchSessions)
.values({
sessionId: params.tabId,
sessionId: params.sessionId,
sandboxSessionId: params.sandboxSessionId ?? null,
sessionName,
model,
@ -271,20 +270,20 @@ async function ensureSessionMeta(
})
.run();
return await readSessionMeta(c, params.tabId);
return await readSessionMeta(c, params.sessionId);
}
async function updateSessionMeta(c: any, tabId: string, values: Record<string, unknown>): Promise<any> {
await ensureSessionMeta(c, { tabId });
async function updateSessionMeta(c: any, sessionId: string, values: Record<string, unknown>): Promise<any> {
await ensureSessionMeta(c, { sessionId });
await c.db
.update(taskWorkbenchSessions)
.set({
...values,
updatedAt: Date.now(),
})
.where(eq(taskWorkbenchSessions.sessionId, tabId))
.where(eq(taskWorkbenchSessions.sessionId, sessionId))
.run();
return await readSessionMeta(c, tabId);
return await readSessionMeta(c, sessionId);
}
async function readSessionMetaBySandboxSessionId(c: any, sandboxSessionId: string): Promise<any | null> {
@ -296,33 +295,25 @@ async function readSessionMetaBySandboxSessionId(c: any, sandboxSessionId: strin
return await readSessionMeta(c, row.sessionId);
}
async function requireReadySessionMeta(c: any, tabId: string): Promise<any> {
const meta = await readSessionMeta(c, tabId);
async function requireReadySessionMeta(c: any, sessionId: string): Promise<any> {
const meta = await readSessionMeta(c, sessionId);
if (!meta) {
throw new Error(`Unknown workbench tab: ${tabId}`);
throw new Error(`Unknown workbench session: ${sessionId}`);
}
if (meta.status !== "ready" || !meta.sandboxSessionId) {
throw new Error(meta.errorMessage ?? "This workbench tab is still preparing");
throw new Error(meta.errorMessage ?? "This workbench session is still preparing");
}
return meta;
}
async function ensureReadySessionMeta(c: any, tabId: string): Promise<any> {
const meta = await readSessionMeta(c, tabId);
export function requireSendableSessionMeta(meta: any, sessionId: string): any {
if (!meta) {
throw new Error(`Unknown workbench tab: ${tabId}`);
throw new Error(`Unknown workbench session: ${sessionId}`);
}
if (meta.status === "ready" && meta.sandboxSessionId) {
return meta;
if (meta.status !== "ready" || !meta.sandboxSessionId) {
throw new Error(`Session is not ready (status: ${meta.status}). Wait for session provisioning to complete.`);
}
if (meta.status === "error") {
throw new Error(meta.errorMessage ?? "This workbench tab failed to prepare");
}
await ensureWorkbenchSession(c, tabId);
return await requireReadySessionMeta(c, tabId);
return meta;
}
function shellFragment(parts: string[]): string {
@ -339,23 +330,23 @@ async function getTaskSandboxRuntime(
): Promise<{
sandbox: any;
sandboxId: string;
providerId: string;
sandboxProviderId: string;
switchTarget: string;
cwd: string;
}> {
const { config } = getActorRuntimeContext();
const sandboxId = stableSandboxId(c);
const providerId = resolveSandboxProviderId(config, record.providerId ?? c.state.providerId ?? null);
const sandbox = await getOrCreateTaskSandbox(c, c.state.workspaceId, sandboxId, {});
const sandboxProviderId = resolveSandboxProviderId(config, record.sandboxProviderId ?? c.state.sandboxProviderId ?? null);
const sandbox = await getOrCreateTaskSandbox(c, c.state.organizationId, sandboxId, {});
const actorId = typeof sandbox.resolve === "function" ? await sandbox.resolve().catch(() => null) : null;
const switchTarget = providerId === "local" ? `sandbox://local/${sandboxId}` : `sandbox://e2b/${sandboxId}`;
const switchTarget = sandboxProviderId === "local" ? `sandbox://local/${sandboxId}` : `sandbox://e2b/${sandboxId}`;
const now = Date.now();
await c.db
.insert(taskSandboxes)
.values({
sandboxId,
providerId,
sandboxProviderId,
sandboxActorId: typeof actorId === "string" ? actorId : null,
switchTarget,
cwd: SANDBOX_REPO_CWD,
@ -366,7 +357,7 @@ async function getTaskSandboxRuntime(
.onConflictDoUpdate({
target: taskSandboxes.sandboxId,
set: {
providerId,
sandboxProviderId,
sandboxActorId: typeof actorId === "string" ? actorId : null,
switchTarget,
cwd: SANDBOX_REPO_CWD,
@ -389,7 +380,7 @@ async function getTaskSandboxRuntime(
return {
sandbox,
sandboxId,
providerId,
sandboxProviderId,
switchTarget,
cwd: SANDBOX_REPO_CWD,
};
@ -400,17 +391,10 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any): Promise<voi
throw new Error("cannot prepare a sandbox repo before the task branch exists");
}
const { driver } = getActorRuntimeContext();
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
let repoLocalPath = c.state.repoLocalPath;
if (!repoLocalPath) {
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
const ensured = await project.ensure({ remoteUrl: c.state.repoRemote });
repoLocalPath = ensured.localPath;
c.state.repoLocalPath = repoLocalPath;
}
const baseRef = await driver.git.remoteDefaultBaseRef(repoLocalPath);
const auth = await resolveOrganizationGithubAuth(c, c.state.organizationId);
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote);
const metadata = await repository.getRepositoryMetadata({});
const baseRef = metadata.defaultBranch ?? "main";
const sandboxRepoRoot = dirname(SANDBOX_REPO_CWD);
const script = [
"set -euo pipefail",
@ -665,7 +649,7 @@ async function readSessionTranscript(c: any, record: any, sessionId: string) {
return [];
}
const sandbox = getTaskSandbox(c, c.state.workspaceId, sandboxId);
const sandbox = getTaskSandbox(c, c.state.organizationId, sandboxId);
const page = await sandbox.getEvents({
sessionId,
limit: 100,
@ -681,8 +665,8 @@ async function readSessionTranscript(c: any, record: any, sessionId: string) {
}));
}
async function writeSessionTranscript(c: any, tabId: string, transcript: Array<any>): Promise<void> {
await updateSessionMeta(c, tabId, {
async function writeSessionTranscript(c: any, sessionId: string, transcript: Array<any>): Promise<void> {
await updateSessionMeta(c, sessionId, {
transcriptJson: JSON.stringify(transcript),
transcriptUpdatedAt: Date.now(),
});
@ -697,12 +681,12 @@ async function enqueueWorkbenchRefresh(
await self.send(command, body, { wait: false });
}
async function enqueueWorkbenchEnsureSession(c: any, tabId: string): Promise<void> {
async function enqueueWorkbenchEnsureSession(c: any, sessionId: string): Promise<void> {
const self = selfTask(c);
await self.send(
"task.command.workbench.ensure_session",
{
tabId,
sessionId,
},
{
wait: false,
@ -750,8 +734,8 @@ async function readPullRequestSummary(c: any, branchName: string | null) {
}
try {
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
return await project.getPullRequestForBranch({ branchName });
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote);
return await repository.getPullRequestForBranch({ branchName });
} catch {
return null;
}
@ -762,7 +746,7 @@ export async function ensureWorkbenchSeeded(c: any): Promise<any> {
const record = await getCurrentRecord({ db: c.db, state: c.state });
if (record.activeSessionId) {
await ensureSessionMeta(c, {
tabId: record.activeSessionId,
sessionId: record.activeSessionId,
sandboxSessionId: record.activeSessionId,
model: defaultModelForAgent(record.agentType),
sessionName: "Session 1",
@ -791,7 +775,8 @@ function buildSessionSummary(record: any, meta: any): any {
return {
id: meta.id,
sessionId: derivedSandboxSessionId,
sessionId: meta.sessionId,
sandboxSessionId: derivedSandboxSessionId,
sessionName: meta.sessionName,
agent: agentKindForModel(meta.model),
model: meta.model,
@ -806,9 +791,8 @@ function buildSessionSummary(record: any, meta: any): any {
function buildSessionDetailFromMeta(record: any, meta: any): any {
const summary = buildSessionSummary(record, meta);
return {
sessionId: meta.tabId,
tabId: meta.tabId,
sandboxSessionId: summary.sessionId,
sessionId: meta.sessionId,
sandboxSessionId: summary.sandboxSessionId ?? null,
sessionName: summary.sessionName,
agent: summary.agent,
model: summary.model,
@ -828,7 +812,7 @@ function buildSessionDetailFromMeta(record: any, meta: any): any {
/**
* Builds a WorkbenchTaskSummary from local task actor state. Task actors push
* this to the parent workspace actor so workspace sidebar reads stay local.
* this to the parent organization actor so organization sidebar reads stay local.
*/
export async function buildTaskSummary(c: any): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
@ -874,7 +858,7 @@ export async function buildTaskDetail(c: any): Promise<any> {
fileTree: gitState.fileTree,
minutesUsed: 0,
sandboxes: (record.sandboxes ?? []).map((sandbox: any) => ({
providerId: sandbox.providerId,
sandboxProviderId: sandbox.sandboxProviderId,
sandboxId: sandbox.sandboxId,
cwd: sandbox.cwd ?? null,
})),
@ -883,13 +867,13 @@ export async function buildTaskDetail(c: any): Promise<any> {
}
/**
* Builds a WorkbenchSessionDetail for a specific session tab.
* Builds a WorkbenchSessionDetail for a specific session.
*/
export async function buildSessionDetail(c: any, tabId: string): Promise<any> {
export async function buildSessionDetail(c: any, sessionId: string): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
const meta = await readSessionMeta(c, tabId);
const meta = await readSessionMeta(c, sessionId);
if (!meta || meta.closed) {
throw new Error(`Unknown workbench session tab: ${tabId}`);
throw new Error(`Unknown workbench session: ${sessionId}`);
}
if (!meta.sandboxSessionId) {
@ -899,7 +883,7 @@ export async function buildSessionDetail(c: any, tabId: string): Promise<any> {
try {
const transcript = await readSessionTranscript(c, record, meta.sandboxSessionId);
if (JSON.stringify(meta.transcript ?? []) !== JSON.stringify(transcript)) {
await writeSessionTranscript(c, meta.tabId, transcript);
await writeSessionTranscript(c, meta.sessionId, transcript);
return buildSessionDetailFromMeta(record, {
...meta,
transcript,
@ -921,21 +905,21 @@ export async function getTaskDetail(c: any): Promise<any> {
return await buildTaskDetail(c);
}
export async function getSessionDetail(c: any, tabId: string): Promise<any> {
return await buildSessionDetail(c, tabId);
export async function getSessionDetail(c: any, sessionId: string): Promise<any> {
return await buildSessionDetail(c, sessionId);
}
/**
* Replaces the old notifyWorkbenchUpdated pattern.
*
* The task actor emits two kinds of updates:
* - Push summary state up to the parent workspace actor so the sidebar
* - Push summary state up to the parent organization actor so the sidebar
* materialized projection stays current.
* - Broadcast full detail/session payloads down to direct task subscribers.
*/
export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }): Promise<void> {
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
await workspace.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) });
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) });
c.broadcast("taskUpdated", {
type: "taskDetailUpdated",
detail: await buildTaskDetail(c),
@ -964,8 +948,8 @@ export async function refreshWorkbenchSessionTranscript(c: any, sessionId: strin
}
const transcript = await readSessionTranscript(c, record, meta.sandboxSessionId);
await writeSessionTranscript(c, meta.tabId, transcript);
await broadcastTaskUpdate(c, { sessionId: meta.tabId });
await writeSessionTranscript(c, meta.sessionId, transcript);
await broadcastTaskUpdate(c, { sessionId: meta.sessionId });
}
export async function renameWorkbenchTask(c: any, value: string): Promise<void> {
@ -1029,31 +1013,31 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
.run();
c.state.branchName = nextBranch;
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
await project.registerTaskBranch({
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote);
await repository.registerTaskBranch({
taskId: c.state.taskId,
branchName: nextBranch,
});
await broadcastTaskUpdate(c);
}
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
const tabId = `tab-${randomUUID()}`;
export async function createWorkbenchSession(c: any, model?: string): Promise<{ sessionId: string }> {
const sessionId = `session-${randomUUID()}`;
const record = await ensureWorkbenchSeeded(c);
await ensureSessionMeta(c, {
tabId,
sessionId,
model: model ?? defaultModelForAgent(record.agentType),
sandboxSessionId: null,
status: pendingWorkbenchSessionStatus(record),
created: false,
});
await broadcastTaskUpdate(c, { sessionId: tabId });
await enqueueWorkbenchEnsureSession(c, tabId);
return { tabId };
await broadcastTaskUpdate(c, { sessionId: sessionId });
await enqueueWorkbenchEnsureSession(c, sessionId);
return { sessionId };
}
export async function ensureWorkbenchSession(c: any, tabId: string, model?: string): Promise<void> {
const meta = await readSessionMeta(c, tabId);
export async function ensureWorkbenchSession(c: any, sessionId: string, model?: string): Promise<void> {
const meta = await readSessionMeta(c, sessionId);
if (!meta || meta.closed) {
return;
}
@ -1063,12 +1047,12 @@ export async function ensureWorkbenchSession(c: any, tabId: string, model?: stri
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
sessionId: meta.sandboxSessionId,
});
await broadcastTaskUpdate(c, { sessionId: tabId });
await broadcastTaskUpdate(c, { sessionId: sessionId });
return;
}
await updateSessionMeta(c, tabId, {
sandboxSessionId: meta.sandboxSessionId ?? tabId,
await updateSessionMeta(c, sessionId, {
sandboxSessionId: meta.sandboxSessionId ?? sessionId,
status: "pending_session_create",
errorMessage: null,
});
@ -1077,7 +1061,7 @@ export async function ensureWorkbenchSession(c: any, tabId: string, model?: stri
const runtime = await getTaskSandboxRuntime(c, record);
await ensureSandboxRepo(c, runtime.sandbox, record);
await runtime.sandbox.createSession({
id: meta.sandboxSessionId ?? tabId,
id: meta.sandboxSessionId ?? sessionId,
agent: agentTypeForModel(model ?? meta.model ?? defaultModelForAgent(record.agentType)),
model: model ?? meta.model ?? defaultModelForAgent(record.agentType),
sessionInit: {
@ -1085,22 +1069,22 @@ export async function ensureWorkbenchSession(c: any, tabId: string, model?: stri
},
});
await updateSessionMeta(c, tabId, {
sandboxSessionId: meta.sandboxSessionId ?? tabId,
await updateSessionMeta(c, sessionId, {
sandboxSessionId: meta.sandboxSessionId ?? sessionId,
status: "ready",
errorMessage: null,
});
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
sessionId: meta.sandboxSessionId ?? tabId,
sessionId: meta.sandboxSessionId ?? sessionId,
});
} catch (error) {
await updateSessionMeta(c, tabId, {
await updateSessionMeta(c, sessionId, {
status: "error",
errorMessage: error instanceof Error ? error.message : String(error),
});
}
await broadcastTaskUpdate(c, { sessionId: tabId });
await broadcastTaskUpdate(c, { sessionId: sessionId });
}
export async function enqueuePendingWorkbenchSessions(c: any): Promise<void> {
@ -1113,7 +1097,7 @@ export async function enqueuePendingWorkbenchSessions(c: any): Promise<void> {
await self.send(
"task.command.workbench.ensure_session",
{
tabId: row.tabId,
sessionId: row.sessionId,
model: row.model,
},
{
@ -1167,7 +1151,7 @@ export async function changeWorkbenchModel(c: any, sessionId: string, model: str
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));
const sandbox = getTaskSandbox(c, c.state.organizationId, stableSandboxId(c));
await sandbox.destroySession(nextMeta.sandboxSessionId);
nextMeta = await updateSessionMeta(c, sessionId, {
sandboxSessionId: null,
@ -1179,7 +1163,7 @@ export async function changeWorkbenchModel(c: any, sessionId: string, model: str
});
shouldEnsure = true;
} else if (nextMeta.status === "ready" && nextMeta.sandboxSessionId) {
const sandbox = getTaskSandbox(c, c.state.workspaceId, stableSandboxId(c));
const sandbox = getTaskSandbox(c, c.state.organizationId, stableSandboxId(c));
if (typeof sandbox.rawSendSessionMethod === "function") {
try {
await sandbox.rawSendSessionMethod(nextMeta.sandboxSessionId, "session/set_config_option", {
@ -1204,7 +1188,7 @@ export async function changeWorkbenchModel(c: any, sessionId: string, model: str
}
export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
const meta = await ensureReadySessionMeta(c, sessionId);
const meta = requireSendableSessionMeta(await readSessionMeta(c, sessionId), sessionId);
const record = await ensureWorkbenchSeeded(c);
const runtime = await getTaskSandboxRuntime(c, record);
await ensureSandboxRepo(c, runtime.sandbox, record);
@ -1253,7 +1237,7 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
export async function stopWorkbenchSession(c: any, sessionId: string): Promise<void> {
const meta = await requireReadySessionMeta(c, sessionId);
const sandbox = getTaskSandbox(c, c.state.workspaceId, stableSandboxId(c));
const sandbox = getTaskSandbox(c, c.state.organizationId, stableSandboxId(c));
await sandbox.destroySession(meta.sandboxSessionId);
await updateSessionMeta(c, sessionId, {
thinkingSinceMs: null,
@ -1263,7 +1247,7 @@ export async function stopWorkbenchSession(c: any, sessionId: string): Promise<v
export async function syncWorkbenchSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await ensureSessionMeta(c, { tabId: sessionId, sandboxSessionId: sessionId }));
const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await ensureSessionMeta(c, { sessionId: sessionId, sandboxSessionId: sessionId }));
let changed = false;
if (record.activeSessionId === sessionId || record.activeSessionId === meta.sandboxSessionId) {
@ -1317,13 +1301,13 @@ export async function syncWorkbenchSessionStatus(c: any, sessionId: string, stat
}
if (changed) {
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
sessionId,
});
if (status !== "running") {
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
sessionId,
});
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
}
await broadcastTaskUpdate(c, { sessionId: meta.tabId });
await broadcastTaskUpdate(c, { sessionId: meta.sessionId });
}
}
@ -1339,7 +1323,7 @@ export async function closeWorkbenchSession(c: any, sessionId: string): Promise<
return;
}
if (meta.sandboxSessionId) {
const sandbox = getTaskSandbox(c, c.state.workspaceId, stableSandboxId(c));
const sandbox = getTaskSandbox(c, c.state.organizationId, stableSandboxId(c));
await sandbox.destroySession(meta.sandboxSessionId);
}
await updateSessionMeta(c, sessionId, {
@ -1365,10 +1349,10 @@ export async function markWorkbenchUnread(c: any): Promise<void> {
if (!latest) {
return;
}
await updateSessionMeta(c, latest.tabId, {
await updateSessionMeta(c, latest.sessionId, {
unread: 1,
});
await broadcastTaskUpdate(c, { sessionId: latest.tabId });
await broadcastTaskUpdate(c, { sessionId: latest.sessionId });
}
export async function publishWorkbenchPr(c: any): Promise<void> {
@ -1376,17 +1360,17 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
if (!record.branchName) {
throw new Error("cannot publish PR without a branch");
}
let repoLocalPath = c.state.repoLocalPath;
if (!repoLocalPath) {
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
const result = await project.ensure({ remoteUrl: c.state.repoRemote });
repoLocalPath = result.localPath;
c.state.repoLocalPath = repoLocalPath;
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote);
const metadata = await repository.getRepositoryMetadata({});
const repoFullName = metadata.fullName ?? githubRepoFullNameFromRemote(c.state.repoRemote);
if (!repoFullName) {
throw new Error(`Unable to resolve GitHub repository for ${c.state.repoRemote}`);
}
const { driver } = getActorRuntimeContext();
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
const created = await driver.github.createPr(repoLocalPath, record.branchName, record.title ?? c.state.task, undefined, {
const auth = await resolveOrganizationGithubAuth(c, c.state.organizationId);
await driver.github.createPr(repoFullName, record.branchName, record.title ?? c.state.task, undefined, {
githubToken: auth?.githubToken ?? null,
baseBranch: metadata.defaultBranch ?? undefined,
});
await c.db
.update(taskTable)

View file

@ -28,7 +28,7 @@ export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void
if (record.activeSandboxId) {
try {
const sandbox = getTaskSandbox(loopCtx, loopCtx.state.workspaceId, record.activeSandboxId);
const sandbox = getTaskSandbox(loopCtx, loopCtx.state.organizationId, record.activeSandboxId);
const connection = await sandbox.sandboxAgentConnection();
if (typeof connection?.endpoint === "string" && connection.endpoint.length > 0) {
target = connection.endpoint;
@ -78,9 +78,9 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
if (record.activeSandboxId) {
await setTaskState(loopCtx, "archive_release_sandbox", "releasing sandbox");
void withTimeout(getTaskSandbox(loopCtx, loopCtx.state.workspaceId, record.activeSandboxId).destroy(), 45_000, "sandbox destroy").catch((error) => {
void withTimeout(getTaskSandbox(loopCtx, loopCtx.state.organizationId, record.activeSandboxId).destroy(), 45_000, "sandbox destroy").catch((error) => {
logActorWarning("task.commands", "failed to release sandbox during archive", {
workspaceId: loopCtx.state.workspaceId,
organizationId: loopCtx.state.organizationId,
repoId: loopCtx.state.repoId,
taskId: loopCtx.state.taskId,
sandboxId: record.activeSandboxId,
@ -106,7 +106,7 @@ export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
return;
}
await getTaskSandbox(loopCtx, loopCtx.state.workspaceId, record.activeSandboxId).destroy();
await getTaskSandbox(loopCtx, loopCtx.state.organizationId, record.activeSandboxId).destroy();
}
export async function killWriteDbActivity(loopCtx: any, msg: any): Promise<void> {

View file

@ -93,7 +93,7 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
branchName: taskTable.branchName,
title: taskTable.title,
task: taskTable.task,
providerId: taskTable.providerId,
sandboxProviderId: taskTable.sandboxProviderId,
status: taskTable.status,
statusMessage: taskRuntime.statusMessage,
activeSandboxId: taskRuntime.activeSandboxId,
@ -115,7 +115,7 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
const sandboxes = await db
.select({
sandboxId: taskSandboxes.sandboxId,
providerId: taskSandboxes.providerId,
sandboxProviderId: taskSandboxes.sandboxProviderId,
sandboxActorId: taskSandboxes.sandboxActorId,
switchTarget: taskSandboxes.switchTarget,
cwd: taskSandboxes.cwd,
@ -126,21 +126,21 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
.all();
return {
workspaceId: ctx.state.workspaceId,
organizationId: ctx.state.organizationId,
repoId: ctx.state.repoId,
repoRemote: ctx.state.repoRemote,
taskId: ctx.state.taskId,
branchName: row.branchName,
title: row.title,
task: row.task,
providerId: row.providerId,
sandboxProviderId: row.sandboxProviderId,
status: row.status,
statusMessage: row.statusMessage ?? null,
activeSandboxId: row.activeSandboxId ?? null,
activeSessionId: row.activeSessionId ?? null,
sandboxes: sandboxes.map((sb) => ({
sandboxId: sb.sandboxId,
providerId: sb.providerId,
sandboxProviderId: sb.sandboxProviderId,
sandboxActorId: sb.sandboxActorId ?? null,
switchTarget: sb.switchTarget,
cwd: sb.cwd ?? null,
@ -165,8 +165,8 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
export async function appendHistory(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
const client = ctx.client();
const history = await client.history.getOrCreate(historyKey(ctx.state.workspaceId, ctx.state.repoId), {
createWithInput: { workspaceId: ctx.state.workspaceId, repoId: ctx.state.repoId },
const history = await client.history.getOrCreate(historyKey(ctx.state.organizationId, ctx.state.repoId), {
createWithInput: { organizationId: ctx.state.organizationId, repoId: ctx.state.repoId },
});
await history.append({
kind,

View file

@ -1,14 +1,7 @@
import { Loop } from "rivetkit/workflow";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { getCurrentRecord } from "./common.js";
import {
initAssertNameActivity,
initBootstrapDbActivity,
initCompleteActivity,
initEnqueueProvisionActivity,
initEnsureNameActivity,
initFailedActivity,
} from "./init.js";
import { initBootstrapDbActivity, initCompleteActivity, initEnqueueProvisionActivity, initFailedActivity } from "./init.js";
import {
handleArchiveActivity,
handleAttachActivity,
@ -67,12 +60,8 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
await loopCtx.removed("init-failed", "step");
await loopCtx.removed("init-failed-v2", "step");
try {
await loopCtx.step({
name: "init-ensure-name",
timeout: 5 * 60_000,
run: async () => initEnsureNameActivity(loopCtx),
});
await loopCtx.step("init-assert-name", async () => initAssertNameActivity(loopCtx));
await loopCtx.removed("init-ensure-name", "step");
await loopCtx.removed("init-assert-name", "step");
await loopCtx.removed("init-create-sandbox", "step");
await loopCtx.removed("init-ensure-agent", "step");
await loopCtx.removed("init-start-sandbox-instance", "step");
@ -156,11 +145,31 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
}
},
"task.command.workbench.create_session_and_send": async (loopCtx, msg) => {
try {
const created = await loopCtx.step({
name: "workbench-create-session-for-send",
timeout: 5 * 60_000,
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
});
await loopCtx.step({
name: "workbench-send-initial-message",
timeout: 5 * 60_000,
run: async () => sendWorkbenchMessage(loopCtx, created.sessionId, msg.body.text, []),
});
} catch (error) {
logActorWarning("task.workflow", "create_session_and_send failed", {
error: resolveErrorMessage(error),
});
}
await msg.complete({ ok: true });
},
"task.command.workbench.ensure_session": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-ensure-session",
timeout: 5 * 60_000,
run: async () => ensureWorkbenchSession(loopCtx, msg.body.tabId, msg.body?.model),
run: async () => ensureWorkbenchSession(loopCtx, msg.body.sessionId, msg.body?.model),
});
await msg.complete({ ok: true });
},
@ -269,7 +278,16 @@ export async function runTaskWorkflow(ctx: any): Promise<void> {
}
const handler = commandHandlers[msg.name as TaskQueueName];
if (handler) {
await handler(loopCtx, msg);
try {
await handler(loopCtx, msg);
} catch (error) {
const message = resolveErrorMessage(error);
logActorWarning("task.workflow", "task workflow command failed", {
queueName: msg.name,
error: message,
});
await msg.complete({ error: message }).catch(() => {});
}
}
return Loop.continue(undefined);
});

View file

@ -1,10 +1,8 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { resolveCreateFlowDecision } from "../../../services/create-flow.js";
import { resolveWorkspaceGithubAuth } from "../../../services/github-auth.js";
import { getActorRuntimeContext } from "../../context.js";
import { getOrCreateHistory, getOrCreateProject, selfTask } from "../../handles.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { getOrCreateHistory, selfTask } from "../../handles.js";
import { resolveErrorMessage } from "../../logging.js";
import { defaultSandboxProviderId } from "../../../sandbox-config.js";
import { task as taskTable, taskRuntime } from "../db/schema.js";
import { TASK_ROW_ID, appendHistory, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js";
@ -19,9 +17,8 @@ async function ensureTaskRuntimeCacheColumns(db: any): Promise<void> {
export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<void> {
const { config } = getActorRuntimeContext();
const providerId = body?.providerId ?? loopCtx.state.providerId ?? defaultSandboxProviderId(config);
const sandboxProviderId = body?.sandboxProviderId ?? loopCtx.state.sandboxProviderId ?? defaultSandboxProviderId(config);
const now = Date.now();
const initialStatusMessage = loopCtx.state.branchName && loopCtx.state.title ? "provisioning" : "naming";
await ensureTaskRuntimeCacheColumns(loopCtx.db);
@ -32,7 +29,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
branchName: loopCtx.state.branchName,
title: loopCtx.state.title,
task: loopCtx.state.task,
providerId,
sandboxProviderId,
status: "init_bootstrap_db",
agentType: loopCtx.state.agentType ?? config.default_agent,
createdAt: now,
@ -44,7 +41,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
branchName: loopCtx.state.branchName,
title: loopCtx.state.title,
task: loopCtx.state.task,
providerId,
sandboxProviderId,
status: "init_bootstrap_db",
agentType: loopCtx.state.agentType ?? config.default_agent,
updatedAt: now,
@ -60,7 +57,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: initialStatusMessage,
statusMessage: "provisioning",
gitStateJson: null,
gitStateUpdatedAt: null,
provisionStage: "queued",
@ -74,7 +71,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: initialStatusMessage,
statusMessage: "provisioning",
provisionStage: "queued",
provisionStageUpdatedAt: now,
updatedAt: now,
@ -102,7 +99,7 @@ export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Pro
});
} catch (error) {
logActorWarning("task.init", "background provision command failed", {
workspaceId: loopCtx.state.workspaceId,
organizationId: loopCtx.state.organizationId,
repoId: loopCtx.state.repoId,
taskId: loopCtx.state.taskId,
error: resolveErrorMessage(error),
@ -111,106 +108,10 @@ export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Pro
}
}
export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
await setTaskState(loopCtx, "init_ensure_name", "determining title and branch");
const existing = await loopCtx.db
.select({
branchName: taskTable.branchName,
title: taskTable.title,
})
.from(taskTable)
.where(eq(taskTable.id, TASK_ROW_ID))
.get();
if (existing?.branchName && existing?.title) {
loopCtx.state.branchName = existing.branchName;
loopCtx.state.title = existing.title;
return;
}
const { driver } = getActorRuntimeContext();
const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId);
let repoLocalPath = loopCtx.state.repoLocalPath;
if (!repoLocalPath) {
const project = await getOrCreateProject(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.repoRemote);
const result = await project.ensure({ remoteUrl: loopCtx.state.repoRemote });
repoLocalPath = result.localPath;
loopCtx.state.repoLocalPath = repoLocalPath;
}
try {
await driver.git.fetch(repoLocalPath, { githubToken: auth?.githubToken ?? null });
} catch (error) {
logActorWarning("task.init", "fetch before naming failed", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
taskId: loopCtx.state.taskId,
error: resolveErrorMessage(error),
});
}
const remoteBranches = (await driver.git.listRemoteBranches(repoLocalPath, { githubToken: auth?.githubToken ?? null })).map(
(branch: any) => branch.branchName,
);
const project = await getOrCreateProject(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.repoRemote);
const reservedBranches = await project.listReservedBranches({});
const resolved = resolveCreateFlowDecision({
task: loopCtx.state.task,
explicitTitle: loopCtx.state.explicitTitle ?? undefined,
explicitBranchName: loopCtx.state.explicitBranchName ?? undefined,
localBranches: remoteBranches,
taskBranches: reservedBranches,
});
const now = Date.now();
await loopCtx.db
.update(taskTable)
.set({
branchName: resolved.branchName,
title: resolved.title,
updatedAt: now,
})
.where(eq(taskTable.id, TASK_ROW_ID))
.run();
loopCtx.state.branchName = resolved.branchName;
loopCtx.state.title = resolved.title;
loopCtx.state.explicitTitle = null;
loopCtx.state.explicitBranchName = null;
await loopCtx.db
.update(taskRuntime)
.set({
statusMessage: "provisioning",
provisionStage: "repo_prepared",
provisionStageUpdatedAt: now,
updatedAt: now,
})
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
await project.registerTaskBranch({
taskId: loopCtx.state.taskId,
branchName: resolved.branchName,
});
await appendHistory(loopCtx, "task.named", {
title: resolved.title,
branchName: resolved.branchName,
});
}
export async function initAssertNameActivity(loopCtx: any): Promise<void> {
await setTaskState(loopCtx, "init_assert_name", "validating naming");
if (!loopCtx.state.branchName) {
throw new Error("task branchName is not initialized");
}
}
export async function initCompleteActivity(loopCtx: any, body: any): Promise<void> {
const now = Date.now();
const { config } = getActorRuntimeContext();
const providerId = body?.providerId ?? loopCtx.state.providerId ?? defaultSandboxProviderId(config);
const sandboxProviderId = body?.sandboxProviderId ?? loopCtx.state.sandboxProviderId ?? defaultSandboxProviderId(config);
await setTaskState(loopCtx, "init_complete", "task initialized");
await loopCtx.db
@ -224,12 +125,12 @@ export async function initCompleteActivity(loopCtx: any, body: any): Promise<voi
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
const history = await getOrCreateHistory(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId);
const history = await getOrCreateHistory(loopCtx, loopCtx.state.organizationId, loopCtx.state.repoId);
await history.append({
kind: "task.initialized",
taskId: loopCtx.state.taskId,
branchName: loopCtx.state.branchName,
payload: { providerId },
payload: { sandboxProviderId },
});
loopCtx.state.initialized = true;
@ -240,7 +141,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
const detail = resolveErrorDetail(error);
const messages = collectErrorMessages(error);
const { config } = getActorRuntimeContext();
const providerId = loopCtx.state.providerId ?? defaultSandboxProviderId(config);
const sandboxProviderId = loopCtx.state.sandboxProviderId ?? defaultSandboxProviderId(config);
await loopCtx.db
.insert(taskTable)
@ -249,7 +150,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
branchName: loopCtx.state.branchName ?? null,
title: loopCtx.state.title ?? null,
task: loopCtx.state.task,
providerId,
sandboxProviderId,
status: "error",
agentType: loopCtx.state.agentType ?? config.default_agent,
createdAt: now,
@ -261,7 +162,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
branchName: loopCtx.state.branchName ?? null,
title: loopCtx.state.title ?? null,
task: loopCtx.state.task,
providerId,
sandboxProviderId,
status: "error",
agentType: loopCtx.state.agentType ?? config.default_agent,
updatedAt: now,

View file

@ -1,7 +1,7 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { getTaskSandbox } from "../../handles.js";
import { resolveWorkspaceGithubAuth } from "../../../services/github-auth.js";
import { resolveOrganizationGithubAuth } from "../../../services/github-auth.js";
import { taskRuntime, taskSandboxes } from "../db/schema.js";
import { TASK_ROW_ID, appendHistory, getCurrentRecord } from "./common.js";
@ -49,8 +49,8 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive
`git push -u origin ${JSON.stringify(branchName)}`,
].join("; ");
const sandbox = getTaskSandbox(loopCtx, loopCtx.state.workspaceId, activeSandboxId);
const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId);
const sandbox = getTaskSandbox(loopCtx, loopCtx.state.organizationId, activeSandboxId);
const auth = await resolveOrganizationGithubAuth(loopCtx, loopCtx.state.organizationId);
const result = await sandbox.runProcess({
command: "bash",
args: ["-lc", script],

View file

@ -13,6 +13,7 @@ export const TASK_QUEUE_NAMES = [
"task.command.workbench.rename_task",
"task.command.workbench.rename_branch",
"task.command.workbench.create_session",
"task.command.workbench.create_session_and_send",
"task.command.workbench.ensure_session",
"task.command.workbench.rename_session",
"task.command.workbench.set_session_unread",

View file

@ -1,6 +0,0 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/workspace/db/drizzle",
schema: "./src/actors/workspace/db/schema.ts",
});

View file

@ -1,19 +0,0 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { workspaceDb } from "./db/db.js";
import { runWorkspaceWorkflow, WORKSPACE_QUEUE_NAMES, workspaceActions } from "./actions.js";
export const workspace = actor({
db: workspaceDb,
queues: Object.fromEntries(WORKSPACE_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
name: "Workspace",
icon: "compass",
actionTimeout: 5 * 60_000,
},
createState: (_c, workspaceId: string) => ({
workspaceId,
}),
actions: workspaceActions,
run: workflow(runWorkspaceWorkflow),
});