Complete Foundry refactor checklist

This commit is contained in:
Nathan Flurry 2026-03-15 13:38:51 -07:00 committed by Nathan Flurry
parent 40bed3b0a1
commit 13fc9cb318
91 changed files with 5091 additions and 4108 deletions

View file

@ -10,9 +10,8 @@ OrganizationActor
├─ GithubDataActor
├─ RepositoryActor(repo)
│ └─ TaskActor(task)
│ ├─ TaskSessionActor(session) × N
│ │ └─ SessionStatusSyncActor(session) × 0..1
│ └─ Task-local workspace state
│ ├─ taskSessions → session metadata/transcripts
│ └─ taskSandboxes → sandbox instance index
└─ SandboxInstanceActor(sandboxProviderId, sandboxId) × N
```
@ -32,21 +31,20 @@ OrganizationActor (coordinator for repos + auth users)
│ Index tables:
│ ├─ repos → RepositoryActor index (repo catalog)
│ ├─ taskLookup → TaskActor index (taskId → repoId routing)
│ ├─ taskSummaries → TaskActor index (materialized sidebar projection)
│ ├─ authSessionIndex → AuthUserActor index (session token → userId)
│ ├─ authEmailIndex → AuthUserActor index (email → userId)
│ └─ authAccountIndex → AuthUserActor index (OAuth account → userId)
│ ├─ authSessionIndex → UserActor index (session token → userId)
│ ├─ authEmailIndex → UserActor index (email → userId)
│ └─ authAccountIndex → UserActor index (OAuth account → userId)
├─ RepositoryActor (coordinator for tasks)
│ │
│ │ Index tables:
│ │ └─ taskIndex → TaskActor index (taskId → branchName)
│ │ ├─ taskIndex → TaskActor index (taskId → branchName)
│ │ └─ tasks → TaskActor materialized sidebar projection
│ │
│ └─ TaskActor (coordinator for sessions + sandboxes)
│ │
│ │ Index tables:
│ │ ├─ taskWorkspaceSessions → Session index (session metadata, transcript, draft)
│ │ ├─ taskWorkspaceSessions → Session index (session metadata + transcript)
│ │ └─ taskSandboxes → SandboxInstanceActor index (sandbox history)
│ │
│ └─ SandboxInstanceActor (leaf)

View file

@ -1,10 +1,11 @@
// @ts-nocheck
import { and, desc, eq } from "drizzle-orm";
import { actor, queue } from "rivetkit";
import { Loop, workflow } from "rivetkit/workflow";
import { workflow } from "rivetkit/workflow";
import type { AuditLogEvent } from "@sandbox-agent/foundry-shared";
import { auditLogDb } from "./db/db.js";
import { events } from "./db/schema.js";
import { AUDIT_LOG_QUEUE_NAMES, runAuditLogWorkflow } from "./workflow.js";
export interface AuditLogInput {
organizationId: string;
@ -24,46 +25,9 @@ export interface ListAuditLogParams {
limit?: number;
}
export const AUDIT_LOG_QUEUE_NAMES = ["auditLog.command.append"] as const;
async function appendAuditLogRow(loopCtx: any, body: AppendAuditLogCommand): Promise<void> {
const now = Date.now();
await loopCtx.db
.insert(events)
.values({
taskId: body.taskId ?? null,
branchName: body.branchName ?? null,
kind: body.kind,
payloadJson: JSON.stringify(body.payload),
createdAt: now,
})
.run();
}
async function runAuditLogWorkflow(ctx: any): Promise<void> {
await ctx.loop("audit-log-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-audit-log-command", {
names: [...AUDIT_LOG_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
if (msg.name === "auditLog.command.append") {
await loopCtx.step("append-audit-log-row", async () => appendAuditLogRow(loopCtx, msg.body as AppendAuditLogCommand));
await msg.complete({ ok: true });
}
return Loop.continue(undefined);
});
}
export const auditLog = actor({
db: auditLogDb,
queues: {
"auditLog.command.append": queue(),
},
queues: Object.fromEntries(AUDIT_LOG_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
name: "Audit Log",
icon: "database",

View file

@ -0,0 +1,39 @@
// @ts-nocheck
import { Loop } from "rivetkit/workflow";
import { events } from "./db/schema.js";
import type { AppendAuditLogCommand } from "./index.js";
export const AUDIT_LOG_QUEUE_NAMES = ["auditLog.command.append"] as const;
async function appendAuditLogRow(loopCtx: any, body: AppendAuditLogCommand): Promise<void> {
const now = Date.now();
await loopCtx.db
.insert(events)
.values({
taskId: body.taskId ?? null,
branchName: body.branchName ?? null,
kind: body.kind,
payloadJson: JSON.stringify(body.payload),
createdAt: now,
})
.run();
}
export async function runAuditLogWorkflow(ctx: any): Promise<void> {
await ctx.loop("audit-log-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-audit-log-command", {
names: [...AUDIT_LOG_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
if (msg.name === "auditLog.command.append") {
await loopCtx.step("append-audit-log-row", async () => appendAuditLogRow(loopCtx, msg.body as AppendAuditLogCommand));
await msg.complete({ ok: true });
}
return Loop.continue(undefined);
});
}

View file

@ -1,104 +0,0 @@
import type { TaskStatus, SandboxProviderId } from "@sandbox-agent/foundry-shared";
export interface TaskCreatedEvent {
organizationId: string;
repoId: string;
taskId: string;
sandboxProviderId: SandboxProviderId;
branchName: string;
title: string;
}
export interface TaskStatusEvent {
organizationId: string;
repoId: string;
taskId: string;
status: TaskStatus;
message: string;
}
export interface RepositorySnapshotEvent {
organizationId: string;
repoId: string;
updatedAt: number;
}
export interface AgentStartedEvent {
organizationId: string;
repoId: string;
taskId: string;
sessionId: string;
}
export interface AgentIdleEvent {
organizationId: string;
repoId: string;
taskId: string;
sessionId: string;
}
export interface AgentErrorEvent {
organizationId: string;
repoId: string;
taskId: string;
message: string;
}
export interface PrCreatedEvent {
organizationId: string;
repoId: string;
taskId: string;
prNumber: number;
url: string;
}
export interface PrClosedEvent {
organizationId: string;
repoId: string;
taskId: string;
prNumber: number;
merged: boolean;
}
export interface PrReviewEvent {
organizationId: string;
repoId: string;
taskId: string;
prNumber: number;
reviewer: string;
status: string;
}
export interface CiStatusChangedEvent {
organizationId: string;
repoId: string;
taskId: string;
prNumber: number;
status: string;
}
export type TaskStepName = "auto_commit" | "push" | "pr_submit";
export type TaskStepStatus = "started" | "completed" | "skipped" | "failed";
export interface TaskStepEvent {
organizationId: string;
repoId: string;
taskId: string;
step: TaskStepName;
status: TaskStepStatus;
message: string;
}
export interface BranchSwitchedEvent {
organizationId: string;
repoId: string;
taskId: string;
branchName: string;
}
export interface SessionAttachedEvent {
organizationId: string;
repoId: string;
taskId: string;
sessionId: string;
}

View file

@ -18,6 +18,12 @@ const journal = {
tag: "0002_github_branches",
breakpoints: true,
},
{
idx: 3,
when: 1773907200000,
tag: "0003_sync_progress",
breakpoints: true,
},
],
} as const;
@ -79,6 +85,22 @@ CREATE TABLE \`github_pull_requests\` (
\`commit_sha\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0003: `ALTER TABLE \`github_meta\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`github_meta\` ADD \`sync_phase\` text;
--> statement-breakpoint
ALTER TABLE \`github_meta\` ADD \`processed_repository_count\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`github_meta\` ADD \`total_repository_count\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`github_repositories\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`github_members\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`github_pull_requests\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`github_branches\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
`,
} as const,
};

View file

@ -11,6 +11,10 @@ export const githubMeta = sqliteTable(
installationId: integer("installation_id"),
lastSyncLabel: text("last_sync_label").notNull(),
lastSyncAt: integer("last_sync_at"),
syncGeneration: integer("sync_generation").notNull(),
syncPhase: text("sync_phase"),
processedRepositoryCount: integer("processed_repository_count").notNull(),
totalRepositoryCount: integer("total_repository_count").notNull(),
updatedAt: integer("updated_at").notNull(),
},
(table) => [check("github_meta_singleton_id_check", sql`${table.id} = 1`)],
@ -22,6 +26,7 @@ export const githubRepositories = sqliteTable("github_repositories", {
cloneUrl: text("clone_url").notNull(),
private: integer("private").notNull(),
defaultBranch: text("default_branch").notNull(),
syncGeneration: integer("sync_generation").notNull(),
updatedAt: integer("updated_at").notNull(),
});
@ -30,6 +35,7 @@ export const githubBranches = sqliteTable("github_branches", {
repoId: text("repo_id").notNull(),
branchName: text("branch_name").notNull(),
commitSha: text("commit_sha").notNull(),
syncGeneration: integer("sync_generation").notNull(),
updatedAt: integer("updated_at").notNull(),
});
@ -40,6 +46,7 @@ export const githubMembers = sqliteTable("github_members", {
email: text("email"),
role: text("role"),
state: text("state").notNull(),
syncGeneration: integer("sync_generation").notNull(),
updatedAt: integer("updated_at").notNull(),
});
@ -56,5 +63,6 @@ export const githubPullRequests = sqliteTable("github_pull_requests", {
baseRefName: text("base_ref_name").notNull(),
authorLogin: text("author_login"),
isDraft: integer("is_draft").notNull(),
syncGeneration: integer("sync_generation").notNull(),
updatedAt: integer("updated_at").notNull(),
});

View file

@ -1,16 +1,29 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { actor, queue } from "rivetkit";
import { workflow, Loop } from "rivetkit/workflow";
import { workflow } from "rivetkit/workflow";
import type { FoundryOrganization } from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateOrganization, getOrCreateRepository, getTask } from "../handles.js";
import { repoIdFromRemote } from "../../services/repo.js";
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
import { expectQueueResponse } from "../../services/queue.js";
import { organizationWorkflowQueueName } from "../organization/queues.js";
import { repositoryWorkflowQueueName } from "../repository/workflow.js";
import { taskWorkflowQueueName } from "../task/workflow/index.js";
import { githubDataDb } from "./db/db.js";
import { githubBranches, githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js";
import { GITHUB_DATA_QUEUE_NAMES, runGithubDataWorkflow } from "./workflow.js";
const META_ROW_ID = 1;
const SYNC_REPOSITORY_BATCH_SIZE = 10;
type GithubSyncPhase =
| "discovering_repositories"
| "syncing_repositories"
| "syncing_branches"
| "syncing_members"
| "syncing_pull_requests";
interface GithubDataInput {
organizationId: string;
@ -70,6 +83,12 @@ interface ClearStateInput {
label: string;
}
async function sendOrganizationCommand(organization: any, name: Parameters<typeof organizationWorkflowQueueName>[0], body: unknown): Promise<void> {
await expectQueueResponse<{ ok: true }>(
await organization.send(organizationWorkflowQueueName(name), body, { wait: true, timeout: 60_000 }),
);
}
interface PullRequestWebhookInput {
connectedAccount: string;
installationStatus: FoundryOrganization["github"]["installationStatus"];
@ -93,6 +112,19 @@ interface PullRequestWebhookInput {
};
}
interface GithubMetaState {
connectedAccount: string;
installationStatus: FoundryOrganization["github"]["installationStatus"];
syncStatus: FoundryOrganization["github"]["syncStatus"];
installationId: number | null;
lastSyncLabel: string;
lastSyncAt: number | null;
syncGeneration: number;
syncPhase: GithubSyncPhase | null;
processedRepositoryCount: number;
totalRepositoryCount: number;
}
function normalizePrStatus(input: { state: string; isDraft?: boolean; merged?: boolean }): "OPEN" | "DRAFT" | "CLOSED" | "MERGED" {
const state = input.state.trim().toUpperCase();
if (input.merged || state === "MERGED") return "MERGED";
@ -117,7 +149,18 @@ function pullRequestSummaryFromRow(row: any) {
};
}
async function readMeta(c: any) {
function chunkItems<T>(items: T[], size: number): T[][] {
if (items.length === 0) {
return [];
}
const chunks: T[][] = [];
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size));
}
return chunks;
}
export async function readMeta(c: any): Promise<GithubMetaState> {
const row = await c.db.select().from(githubMeta).where(eq(githubMeta.id, META_ROW_ID)).get();
return {
connectedAccount: row?.connectedAccount ?? "",
@ -126,10 +169,14 @@ async function readMeta(c: any) {
installationId: row?.installationId ?? null,
lastSyncLabel: row?.lastSyncLabel ?? "Waiting for first import",
lastSyncAt: row?.lastSyncAt ?? null,
syncGeneration: row?.syncGeneration ?? 0,
syncPhase: (row?.syncPhase ?? null) as GithubSyncPhase | null,
processedRepositoryCount: row?.processedRepositoryCount ?? 0,
totalRepositoryCount: row?.totalRepositoryCount ?? 0,
};
}
async function writeMeta(c: any, patch: Partial<Awaited<ReturnType<typeof readMeta>>>) {
async function writeMeta(c: any, patch: Partial<GithubMetaState>) {
const current = await readMeta(c);
const next = {
...current,
@ -145,6 +192,10 @@ async function writeMeta(c: any, patch: Partial<Awaited<ReturnType<typeof readMe
installationId: next.installationId,
lastSyncLabel: next.lastSyncLabel,
lastSyncAt: next.lastSyncAt,
syncGeneration: next.syncGeneration,
syncPhase: next.syncPhase,
processedRepositoryCount: next.processedRepositoryCount,
totalRepositoryCount: next.totalRepositoryCount,
updatedAt: Date.now(),
})
.onConflictDoUpdate({
@ -156,6 +207,10 @@ async function writeMeta(c: any, patch: Partial<Awaited<ReturnType<typeof readMe
installationId: next.installationId,
lastSyncLabel: next.lastSyncLabel,
lastSyncAt: next.lastSyncAt,
syncGeneration: next.syncGeneration,
syncPhase: next.syncPhase,
processedRepositoryCount: next.processedRepositoryCount,
totalRepositoryCount: next.totalRepositoryCount,
updatedAt: Date.now(),
},
})
@ -163,6 +218,35 @@ async function writeMeta(c: any, patch: Partial<Awaited<ReturnType<typeof readMe
return next;
}
async function publishSyncProgress(c: any, patch: Partial<GithubMetaState>): Promise<GithubMetaState> {
const meta = await writeMeta(c, patch);
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await sendOrganizationCommand(organization, "organization.command.github.sync_progress.apply", {
connectedAccount: meta.connectedAccount,
installationStatus: meta.installationStatus,
installationId: meta.installationId,
syncStatus: meta.syncStatus,
lastSyncLabel: meta.lastSyncLabel,
lastSyncAt: meta.lastSyncAt,
syncGeneration: meta.syncGeneration,
syncPhase: meta.syncPhase,
processedRepositoryCount: meta.processedRepositoryCount,
totalRepositoryCount: meta.totalRepositoryCount,
});
return meta;
}
async function runSyncStep<T>(c: any, name: string, run: () => Promise<T>): Promise<T> {
if (typeof c.step !== "function") {
return await run();
}
return await c.step({
name,
timeout: 90_000,
run,
});
}
async function getOrganizationContext(c: any, overrides?: FullSyncInput) {
const organizationHandle = await getOrCreateOrganization(c, c.state.organizationId);
const organizationState = await organizationHandle.getOrganizationShellStateIfInitialized({});
@ -183,8 +267,7 @@ async function getOrganizationContext(c: any, overrides?: FullSyncInput) {
};
}
async function replaceRepositories(c: any, repositories: GithubRepositoryRecord[], updatedAt: number) {
await c.db.delete(githubRepositories).run();
async function upsertRepositories(c: any, repositories: GithubRepositoryRecord[], updatedAt: number, syncGeneration: number) {
for (const repository of repositories) {
await c.db
.insert(githubRepositories)
@ -194,14 +277,35 @@ async function replaceRepositories(c: any, repositories: GithubRepositoryRecord[
cloneUrl: repository.cloneUrl,
private: repository.private ? 1 : 0,
defaultBranch: repository.defaultBranch,
syncGeneration,
updatedAt,
})
.onConflictDoUpdate({
target: githubRepositories.repoId,
set: {
fullName: repository.fullName,
cloneUrl: repository.cloneUrl,
private: repository.private ? 1 : 0,
defaultBranch: repository.defaultBranch,
syncGeneration,
updatedAt,
},
})
.run();
}
}
async function replaceBranches(c: any, branches: GithubBranchRecord[], updatedAt: number) {
await c.db.delete(githubBranches).run();
async function sweepRepositories(c: any, syncGeneration: number) {
const rows = await c.db.select({ repoId: githubRepositories.repoId, syncGeneration: githubRepositories.syncGeneration }).from(githubRepositories).all();
for (const row of rows) {
if (row.syncGeneration === syncGeneration) {
continue;
}
await c.db.delete(githubRepositories).where(eq(githubRepositories.repoId, row.repoId)).run();
}
}
async function upsertBranches(c: any, branches: GithubBranchRecord[], updatedAt: number, syncGeneration: number) {
for (const branch of branches) {
await c.db
.insert(githubBranches)
@ -210,14 +314,34 @@ async function replaceBranches(c: any, branches: GithubBranchRecord[], updatedAt
repoId: branch.repoId,
branchName: branch.branchName,
commitSha: branch.commitSha,
syncGeneration,
updatedAt,
})
.onConflictDoUpdate({
target: githubBranches.branchId,
set: {
repoId: branch.repoId,
branchName: branch.branchName,
commitSha: branch.commitSha,
syncGeneration,
updatedAt,
},
})
.run();
}
}
async function replaceMembers(c: any, members: GithubMemberRecord[], updatedAt: number) {
await c.db.delete(githubMembers).run();
async function sweepBranches(c: any, syncGeneration: number) {
const rows = await c.db.select({ branchId: githubBranches.branchId, syncGeneration: githubBranches.syncGeneration }).from(githubBranches).all();
for (const row of rows) {
if (row.syncGeneration === syncGeneration) {
continue;
}
await c.db.delete(githubBranches).where(eq(githubBranches.branchId, row.branchId)).run();
}
}
async function upsertMembers(c: any, members: GithubMemberRecord[], updatedAt: number, syncGeneration: number) {
for (const member of members) {
await c.db
.insert(githubMembers)
@ -228,14 +352,36 @@ async function replaceMembers(c: any, members: GithubMemberRecord[], updatedAt:
email: member.email ?? null,
role: member.role ?? null,
state: member.state ?? "active",
syncGeneration,
updatedAt,
})
.onConflictDoUpdate({
target: githubMembers.memberId,
set: {
login: member.login,
displayName: member.name || member.login,
email: member.email ?? null,
role: member.role ?? null,
state: member.state ?? "active",
syncGeneration,
updatedAt,
},
})
.run();
}
}
async function replacePullRequests(c: any, pullRequests: GithubPullRequestRecord[]) {
await c.db.delete(githubPullRequests).run();
async function sweepMembers(c: any, syncGeneration: number) {
const rows = await c.db.select({ memberId: githubMembers.memberId, syncGeneration: githubMembers.syncGeneration }).from(githubMembers).all();
for (const row of rows) {
if (row.syncGeneration === syncGeneration) {
continue;
}
await c.db.delete(githubMembers).where(eq(githubMembers.memberId, row.memberId)).run();
}
}
async function upsertPullRequests(c: any, pullRequests: GithubPullRequestRecord[], syncGeneration: number) {
for (const pullRequest of pullRequests) {
await c.db
.insert(githubPullRequests)
@ -252,19 +398,54 @@ async function replacePullRequests(c: any, pullRequests: GithubPullRequestRecord
baseRefName: pullRequest.baseRefName,
authorLogin: pullRequest.authorLogin ?? null,
isDraft: pullRequest.isDraft ? 1 : 0,
syncGeneration,
updatedAt: pullRequest.updatedAt,
})
.onConflictDoUpdate({
target: githubPullRequests.prId,
set: {
repoId: pullRequest.repoId,
repoFullName: pullRequest.repoFullName,
number: pullRequest.number,
title: pullRequest.title,
body: pullRequest.body ?? null,
state: pullRequest.state,
url: pullRequest.url,
headRefName: pullRequest.headRefName,
baseRefName: pullRequest.baseRefName,
authorLogin: pullRequest.authorLogin ?? null,
isDraft: pullRequest.isDraft ? 1 : 0,
syncGeneration,
updatedAt: pullRequest.updatedAt,
},
})
.run();
}
}
async function refreshTaskSummaryForBranch(c: any, repoId: string, branchName: string) {
async function sweepPullRequests(c: any, syncGeneration: number) {
const rows = await c.db.select({ prId: githubPullRequests.prId, syncGeneration: githubPullRequests.syncGeneration }).from(githubPullRequests).all();
for (const row of rows) {
if (row.syncGeneration === syncGeneration) {
continue;
}
await c.db.delete(githubPullRequests).where(eq(githubPullRequests.prId, row.prId)).run();
}
}
async function refreshTaskSummaryForBranch(c: any, repoId: string, branchName: string, pullRequest: ReturnType<typeof pullRequestSummaryFromRow> | null) {
const repositoryRecord = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, repoId)).get();
if (!repositoryRecord) {
return;
}
const repository = await getOrCreateRepository(c, c.state.organizationId, repoId, repositoryRecord.cloneUrl);
await repository.refreshTaskSummaryForBranch({ branchName });
const repository = await getOrCreateRepository(c, c.state.organizationId, repoId);
await expectQueueResponse<{ ok: true }>(
await repository.send(
repositoryWorkflowQueueName("repository.command.refreshTaskSummaryForBranch"),
{ branchName, pullRequest },
{ wait: true, timeout: 10_000 },
),
);
}
async function emitPullRequestChangeEvents(c: any, beforeRows: any[], afterRows: any[]) {
@ -286,14 +467,14 @@ async function emitPullRequestChangeEvents(c: any, beforeRows: any[], afterRows:
if (!changed) {
continue;
}
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName);
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName, pullRequestSummaryFromRow(row));
}
for (const [prId, row] of beforeById) {
if (afterById.has(prId)) {
continue;
}
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName);
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName, null);
}
}
@ -302,7 +483,7 @@ async function autoArchiveTaskForClosedPullRequest(c: any, row: any) {
if (!repositoryRecord) {
return;
}
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, repositoryRecord.cloneUrl);
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId);
const match = await repository.findTaskForBranch({
branchName: row.headRefName,
});
@ -311,7 +492,7 @@ async function autoArchiveTaskForClosedPullRequest(c: any, row: any) {
}
try {
const task = getTask(c, c.state.organizationId, row.repoId, match.taskId);
await task.archive({ reason: `PR ${String(row.state).toLowerCase()}` });
await task.send(taskWorkflowQueueName("task.command.archive"), { reason: `PR ${String(row.state).toLowerCase()}` }, { wait: false });
} catch {
// Best-effort only. Task summary refresh will still clear the PR state.
}
@ -363,8 +544,7 @@ async function resolveMembers(c: any, context: Awaited<ReturnType<typeof getOrga
return await appShell.github.listOrganizationMembers(context.accessToken, context.githubLogin);
}
async function resolvePullRequests(
c: any,
async function listPullRequestsForRepositories(
context: Awaited<ReturnType<typeof getOrganizationContext>>,
repositories: GithubRepositoryRecord[],
): Promise<GithubPullRequestRecord[]> {
@ -448,11 +628,51 @@ async function listRepositoryBranchesForContext(
}
async function resolveBranches(
_c: any,
c: any,
context: Awaited<ReturnType<typeof getOrganizationContext>>,
repositories: GithubRepositoryRecord[],
): Promise<GithubBranchRecord[]> {
return (await Promise.all(repositories.map((repository) => listRepositoryBranchesForContext(context, repository)))).flat();
onBatch?: (branches: GithubBranchRecord[]) => Promise<void>,
onProgress?: (processedRepositoryCount: number, totalRepositoryCount: number) => Promise<void>,
): Promise<void> {
const batches = chunkItems(repositories, SYNC_REPOSITORY_BATCH_SIZE);
let processedRepositoryCount = 0;
for (const batch of batches) {
const batchBranches = await runSyncStep(c, `github-sync-branches-${processedRepositoryCount / SYNC_REPOSITORY_BATCH_SIZE + 1}`, async () =>
(await Promise.all(batch.map((repository) => listRepositoryBranchesForContext(context, repository)))).flat(),
);
if (onBatch) {
await onBatch(batchBranches);
}
processedRepositoryCount += batch.length;
if (onProgress) {
await onProgress(processedRepositoryCount, repositories.length);
}
}
}
async function resolvePullRequests(
c: any,
context: Awaited<ReturnType<typeof getOrganizationContext>>,
repositories: GithubRepositoryRecord[],
onBatch?: (pullRequests: GithubPullRequestRecord[]) => Promise<void>,
onProgress?: (processedRepositoryCount: number, totalRepositoryCount: number) => Promise<void>,
): Promise<void> {
const batches = chunkItems(repositories, SYNC_REPOSITORY_BATCH_SIZE);
let processedRepositoryCount = 0;
for (const batch of batches) {
const batchPullRequests = await runSyncStep(c, `github-sync-pull-requests-${processedRepositoryCount / SYNC_REPOSITORY_BATCH_SIZE + 1}`, async () =>
listPullRequestsForRepositories(context, batch),
);
if (onBatch) {
await onBatch(batchPullRequests);
}
processedRepositoryCount += batch.length;
if (onProgress) {
await onProgress(processedRepositoryCount, repositories.length);
}
}
}
async function refreshRepositoryBranches(
@ -461,6 +681,7 @@ async function refreshRepositoryBranches(
repository: GithubRepositoryRecord,
updatedAt: number,
): Promise<void> {
const currentMeta = await readMeta(c);
const nextBranches = await listRepositoryBranchesForContext(context, repository);
await c.db
.delete(githubBranches)
@ -475,6 +696,7 @@ async function refreshRepositoryBranches(
repoId: branch.repoId,
branchName: branch.branchName,
commitSha: branch.commitSha,
syncGeneration: currentMeta.syncGeneration,
updatedAt,
})
.run();
@ -485,118 +707,176 @@ async function readAllPullRequestRows(c: any) {
return await c.db.select().from(githubPullRequests).all();
}
async function runFullSync(c: any, input: FullSyncInput = {}) {
export async function runFullSync(c: any, input: FullSyncInput = {}) {
const startedAt = Date.now();
const beforeRows = await readAllPullRequestRows(c);
const context = await getOrganizationContext(c, input);
const currentMeta = await readMeta(c);
let context: Awaited<ReturnType<typeof getOrganizationContext>> | null = null;
let syncGeneration = currentMeta.syncGeneration + 1;
await writeMeta(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: input.label?.trim() || "Syncing GitHub data...",
});
try {
context = await getOrganizationContext(c, input);
syncGeneration = currentMeta.syncGeneration + 1;
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 organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyGithubDataProjection({
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "synced",
lastSyncLabel: repositories.length > 0 ? `Synced ${repositories.length} repositories` : "No repositories available",
lastSyncAt: startedAt,
repositories,
});
const meta = await writeMeta(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "synced",
lastSyncLabel: repositories.length > 0 ? `Synced ${repositories.length} repositories` : "No repositories available",
lastSyncAt: startedAt,
});
const afterRows = await readAllPullRequestRows(c);
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
return {
...meta,
repositoryCount: repositories.length,
memberCount: members.length,
pullRequestCount: afterRows.length,
};
}
const GITHUB_DATA_QUEUE_NAMES = ["githubData.command.syncRepos"] as const;
async function runGithubDataWorkflow(ctx: any): Promise<void> {
// Initial sync: if this actor was just created and has never synced,
// kick off the first full sync automatically.
await ctx.step({
name: "github-data-initial-sync",
timeout: 5 * 60_000,
run: async () => {
const meta = await readMeta(ctx);
if (meta.syncStatus !== "pending") {
return; // Already synced or syncing — skip initial sync
}
try {
await runFullSync(ctx, { label: "Importing repository catalog..." });
} catch (error) {
// Best-effort initial sync. Write the error to meta so the client
// sees the failure and can trigger a manual retry.
const currentMeta = await readMeta(ctx);
const organization = await getOrCreateOrganization(ctx, ctx.state.organizationId);
await organization.markOrganizationSyncFailed({
message: error instanceof Error ? error.message : "GitHub import failed",
installationStatus: currentMeta.installationStatus,
});
}
},
});
// Command loop for explicit sync requests (reload, re-import, etc.)
await ctx.loop("github-data-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-github-data-command", {
names: [...GITHUB_DATA_QUEUE_NAMES],
completable: true,
await publishSyncProgress(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: input.label?.trim() || "Syncing GitHub data...",
syncGeneration,
syncPhase: "discovering_repositories",
processedRepositoryCount: 0,
totalRepositoryCount: 0,
});
if (!msg) {
return Loop.continue(undefined);
}
try {
if (msg.name === "githubData.command.syncRepos") {
await loopCtx.step({
name: "github-data-sync-repos",
timeout: 5 * 60_000,
run: async () => {
const body = msg.body as FullSyncInput;
await runFullSync(loopCtx, body);
},
const repositories = await runSyncStep(c, "github-sync-repositories", async () => resolveRepositories(c, context));
const totalRepositoryCount = repositories.length;
await publishSyncProgress(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: totalRepositoryCount > 0 ? `Importing ${totalRepositoryCount} repositories...` : "No repositories available",
syncGeneration,
syncPhase: "syncing_repositories",
processedRepositoryCount: totalRepositoryCount,
totalRepositoryCount,
});
await upsertRepositories(c, repositories, startedAt, syncGeneration);
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await sendOrganizationCommand(organization, "organization.command.github.data_projection.apply", {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: totalRepositoryCount > 0 ? `Imported ${totalRepositoryCount} repositories` : "No repositories available",
lastSyncAt: currentMeta.lastSyncAt,
syncGeneration,
syncPhase: totalRepositoryCount > 0 ? "syncing_branches" : null,
processedRepositoryCount: 0,
totalRepositoryCount,
repositories,
});
await resolveBranches(
c,
context,
repositories,
async (batchBranches) => {
await upsertBranches(c, batchBranches, startedAt, syncGeneration);
},
async (processedRepositoryCount, repositoryCount) => {
await publishSyncProgress(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: `Synced branches for ${processedRepositoryCount} of ${repositoryCount} repositories`,
syncGeneration,
syncPhase: "syncing_branches",
processedRepositoryCount,
totalRepositoryCount: repositoryCount,
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await msg.complete({ error: message }).catch(() => {});
}
},
);
return Loop.continue(undefined);
});
await publishSyncProgress(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: "Syncing GitHub members...",
syncGeneration,
syncPhase: "syncing_members",
processedRepositoryCount: totalRepositoryCount,
totalRepositoryCount,
});
const members = await runSyncStep(c, "github-sync-members", async () => resolveMembers(c, context));
await upsertMembers(c, members, startedAt, syncGeneration);
await sweepMembers(c, syncGeneration);
await resolvePullRequests(
c,
context,
repositories,
async (batchPullRequests) => {
await upsertPullRequests(c, batchPullRequests, syncGeneration);
},
async (processedRepositoryCount, repositoryCount) => {
await publishSyncProgress(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: `Synced pull requests for ${processedRepositoryCount} of ${repositoryCount} repositories`,
syncGeneration,
syncPhase: "syncing_pull_requests",
processedRepositoryCount,
totalRepositoryCount: repositoryCount,
});
},
);
await sweepBranches(c, syncGeneration);
await sweepPullRequests(c, syncGeneration);
await sweepRepositories(c, syncGeneration);
await sendOrganizationCommand(organization, "organization.command.github.data_projection.apply", {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "synced",
lastSyncLabel: totalRepositoryCount > 0 ? `Synced ${totalRepositoryCount} repositories` : "No repositories available",
lastSyncAt: startedAt,
syncGeneration,
syncPhase: null,
processedRepositoryCount: totalRepositoryCount,
totalRepositoryCount,
repositories,
});
const meta = await writeMeta(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "synced",
lastSyncLabel: totalRepositoryCount > 0 ? `Synced ${totalRepositoryCount} repositories` : "No repositories available",
lastSyncAt: startedAt,
syncGeneration,
syncPhase: null,
processedRepositoryCount: totalRepositoryCount,
totalRepositoryCount,
});
const afterRows = await readAllPullRequestRows(c);
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
return {
...meta,
repositoryCount: repositories.length,
memberCount: members.length,
pullRequestCount: afterRows.length,
};
} catch (error) {
const message = error instanceof Error ? error.message : "GitHub import failed";
await publishSyncProgress(c, {
connectedAccount: context?.connectedAccount ?? currentMeta.connectedAccount,
installationStatus: context?.installationStatus ?? currentMeta.installationStatus,
installationId: context?.installationId ?? currentMeta.installationId,
syncStatus: "error",
lastSyncLabel: message,
syncGeneration,
syncPhase: null,
processedRepositoryCount: 0,
totalRepositoryCount: 0,
});
throw error;
}
}
export const githubData = actor({
@ -651,11 +931,6 @@ export const githubData = actor({
};
},
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
@ -666,36 +941,10 @@ export const githubData = actor({
.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);
},
},
});
async getPullRequestForBranch(c, input: { repoId: string; branchName: string }) {
const rows = await c.db.select().from(githubPullRequests).where(eq(githubPullRequests.repoId, input.repoId)).all();
const match = rows.find((candidate) => candidate.headRefName === input.branchName) ?? null;
if (!match) {
return null;
}
return {
number: match.number,
status: match.isDraft ? ("draft" as const) : ("ready" as const),
};
},
async adminFullSync(c, input: FullSyncInput = {}) {
return await runFullSync(c, input);
},
async adminReloadOrganization(c) {
return await runFullSync(c, { label: "Reloading GitHub organization..." });
},
async adminReloadAllPullRequests(c) {
return await runFullSync(c, { label: "Reloading GitHub pull requests..." });
},
async reloadRepository(c, input: { repoId: string }) {
export async function reloadRepositoryMutation(c: any, input: { repoId: string }) {
const context = await getOrganizationContext(c);
const current = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, input.repoId)).get();
if (!current) {
@ -713,6 +962,7 @@ export const githubData = actor({
}
const updatedAt = Date.now();
const currentMeta = await readMeta(c);
await c.db
.insert(githubRepositories)
.values({
@ -721,6 +971,7 @@ export const githubData = actor({
cloneUrl: repository.cloneUrl,
private: repository.private ? 1 : 0,
defaultBranch: repository.defaultBranch,
syncGeneration: currentMeta.syncGeneration,
updatedAt,
})
.onConflictDoUpdate({
@ -730,6 +981,7 @@ export const githubData = actor({
cloneUrl: repository.cloneUrl,
private: repository.private ? 1 : 0,
defaultBranch: repository.defaultBranch,
syncGeneration: currentMeta.syncGeneration,
updatedAt,
},
})
@ -747,7 +999,7 @@ export const githubData = actor({
);
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyGithubRepositoryProjection({
await sendOrganizationCommand(organization, "organization.command.github.repository_projection.apply", {
repoId: input.repoId,
remoteUrl: repository.cloneUrl,
});
@ -758,98 +1010,11 @@ export const githubData = actor({
private: repository.private,
defaultBranch: repository.defaultBranch,
};
},
async reloadPullRequest(c, input: { repoId: string; prNumber: number }) {
const repository = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, input.repoId)).get();
if (!repository) {
throw new Error(`Unknown GitHub repository: ${input.repoId}`);
}
const context = await getOrganizationContext(c);
const { appShell } = getActorRuntimeContext();
const pullRequest =
context.installationId != null
? await appShell.github.getInstallationPullRequest(context.installationId, repository.fullName, input.prNumber)
: context.accessToken
? await appShell.github.getUserPullRequest(context.accessToken, repository.fullName, input.prNumber)
: null;
if (!pullRequest) {
throw new Error(`Unable to reload pull request #${input.prNumber} for ${repository.fullName}`);
}
}
export async function clearStateMutation(c: any, input: ClearStateInput) {
const beforeRows = await readAllPullRequestRows(c);
const updatedAt = Date.now();
const nextState = normalizePrStatus(pullRequest);
const prId = `${input.repoId}#${input.prNumber}`;
if (nextState === "CLOSED" || nextState === "MERGED") {
await c.db.delete(githubPullRequests).where(eq(githubPullRequests.prId, prId)).run();
} else {
await c.db
.insert(githubPullRequests)
.values({
prId,
repoId: input.repoId,
repoFullName: repository.fullName,
number: pullRequest.number,
title: pullRequest.title,
body: pullRequest.body ?? null,
state: nextState,
url: pullRequest.url,
headRefName: pullRequest.headRefName,
baseRefName: pullRequest.baseRefName,
authorLogin: pullRequest.authorLogin ?? null,
isDraft: pullRequest.isDraft ? 1 : 0,
updatedAt,
})
.onConflictDoUpdate({
target: githubPullRequests.prId,
set: {
title: pullRequest.title,
body: pullRequest.body ?? null,
state: nextState,
url: pullRequest.url,
headRefName: pullRequest.headRefName,
baseRefName: pullRequest.baseRefName,
authorLogin: pullRequest.authorLogin ?? null,
isDraft: pullRequest.isDraft ? 1 : 0,
updatedAt,
},
})
.run();
}
const afterRows = await readAllPullRequestRows(c);
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
const closed = afterRows.find((row) => row.prId === prId);
if (!closed && (nextState === "CLOSED" || nextState === "MERGED")) {
const previous = beforeRows.find((row) => row.prId === prId);
if (previous) {
await autoArchiveTaskForClosedPullRequest(c, {
...previous,
state: nextState,
});
}
}
return pullRequestSummaryFromRow(
afterRows.find((row) => row.prId === prId) ?? {
prId,
repoId: input.repoId,
repoFullName: repository.fullName,
number: input.prNumber,
title: pullRequest.title,
state: nextState,
url: pullRequest.url,
headRefName: pullRequest.headRefName,
baseRefName: pullRequest.baseRefName,
authorLogin: pullRequest.authorLogin ?? null,
isDraft: pullRequest.isDraft ? 1 : 0,
updatedAt,
},
);
},
async adminClearState(c, input: ClearStateInput) {
const beforeRows = await readAllPullRequestRows(c);
const currentMeta = await readMeta(c);
await c.db.delete(githubPullRequests).run();
await c.db.delete(githubBranches).run();
await c.db.delete(githubRepositories).run();
@ -861,26 +1026,35 @@ export const githubData = actor({
syncStatus: "pending",
lastSyncLabel: input.label,
lastSyncAt: null,
syncGeneration: currentMeta.syncGeneration,
syncPhase: null,
processedRepositoryCount: 0,
totalRepositoryCount: 0,
});
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyGithubDataProjection({
await sendOrganizationCommand(organization, "organization.command.github.data_projection.apply", {
connectedAccount: input.connectedAccount,
installationStatus: input.installationStatus,
installationId: input.installationId,
syncStatus: "pending",
lastSyncLabel: input.label,
lastSyncAt: null,
syncGeneration: currentMeta.syncGeneration,
syncPhase: null,
processedRepositoryCount: 0,
totalRepositoryCount: 0,
repositories: [],
});
await emitPullRequestChangeEvents(c, beforeRows, []);
},
}
async handlePullRequestWebhook(c, input: PullRequestWebhookInput) {
export async function handlePullRequestWebhookMutation(c: any, 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 currentMeta = await readMeta(c);
const state = normalizePrStatus(input.pullRequest);
const prId = `${repoId}#${input.pullRequest.number}`;
@ -892,6 +1066,7 @@ export const githubData = actor({
cloneUrl: input.repository.cloneUrl,
private: input.repository.private ? 1 : 0,
defaultBranch: currentRepository?.defaultBranch ?? input.pullRequest.baseRefName ?? "main",
syncGeneration: currentMeta.syncGeneration,
updatedAt,
})
.onConflictDoUpdate({
@ -901,6 +1076,7 @@ export const githubData = actor({
cloneUrl: input.repository.cloneUrl,
private: input.repository.private ? 1 : 0,
defaultBranch: currentRepository?.defaultBranch ?? input.pullRequest.baseRefName ?? "main",
syncGeneration: currentMeta.syncGeneration,
updatedAt,
},
})
@ -924,6 +1100,7 @@ export const githubData = actor({
baseRefName: input.pullRequest.baseRefName,
authorLogin: input.pullRequest.authorLogin ?? null,
isDraft: input.pullRequest.isDraft ? 1 : 0,
syncGeneration: currentMeta.syncGeneration,
updatedAt,
})
.onConflictDoUpdate({
@ -937,23 +1114,27 @@ export const githubData = actor({
baseRefName: input.pullRequest.baseRefName,
authorLogin: input.pullRequest.authorLogin ?? null,
isDraft: input.pullRequest.isDraft ? 1 : 0,
syncGeneration: currentMeta.syncGeneration,
updatedAt,
},
})
.run();
}
await writeMeta(c, {
await publishSyncProgress(c, {
connectedAccount: input.connectedAccount,
installationStatus: input.installationStatus,
installationId: input.installationId,
syncStatus: "synced",
lastSyncLabel: "GitHub webhook received",
lastSyncAt: updatedAt,
syncPhase: null,
processedRepositoryCount: 0,
totalRepositoryCount: 0,
});
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyGithubRepositoryProjection({
await sendOrganizationCommand(organization, "organization.command.github.repository_projection.apply", {
repoId,
remoteUrl: input.repository.cloneUrl,
});
@ -969,6 +1150,4 @@ export const githubData = actor({
});
}
}
},
},
});
}

View file

@ -0,0 +1,76 @@
// @ts-nocheck
import { Loop } from "rivetkit/workflow";
import { clearStateMutation, handlePullRequestWebhookMutation, reloadRepositoryMutation, runFullSync } from "./index.js";
export const GITHUB_DATA_QUEUE_NAMES = [
"githubData.command.syncRepos",
"githubData.command.reloadRepository",
"githubData.command.clearState",
"githubData.command.handlePullRequestWebhook",
] as const;
export type GithubDataQueueName = (typeof GITHUB_DATA_QUEUE_NAMES)[number];
export function githubDataWorkflowQueueName(name: GithubDataQueueName): GithubDataQueueName {
return name;
}
export async function runGithubDataWorkflow(ctx: any): Promise<void> {
const meta = await ctx.step({
name: "github-data-read-meta",
timeout: 30_000,
run: async () => {
const { readMeta } = await import("./index.js");
return await readMeta(ctx);
},
});
if (meta.syncStatus === "pending") {
try {
await runFullSync(ctx, { label: "Importing repository catalog..." });
} catch {
// Best-effort initial sync. runFullSync persists the failure state.
}
}
await ctx.loop("github-data-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-github-data-command", {
names: [...GITHUB_DATA_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
try {
if (msg.name === "githubData.command.syncRepos") {
await runFullSync(loopCtx, msg.body);
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "githubData.command.reloadRepository") {
const result = await reloadRepositoryMutation(loopCtx, msg.body);
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "githubData.command.clearState") {
await clearStateMutation(loopCtx, msg.body);
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "githubData.command.handlePullRequestWebhook") {
await handlePullRequestWebhookMutation(loopCtx, msg.body);
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await msg.complete({ error: message }).catch(() => {});
}
return Loop.continue(undefined);
});
}

View file

@ -20,12 +20,11 @@ export function getUser(c: any, userId: string) {
return actorClient(c).user.get(userKey(userId));
}
export async function getOrCreateRepository(c: any, organizationId: string, repoId: string, remoteUrl: string) {
export async function getOrCreateRepository(c: any, organizationId: string, repoId: string) {
return await actorClient(c).repository.getOrCreate(repositoryKey(organizationId, repoId), {
createWithInput: {
organizationId,
repoId,
remoteUrl,
},
});
}

View file

@ -32,7 +32,6 @@ export const registry = setup({
});
export * from "./context.js";
export * from "./events.js";
export * from "./audit-log/index.js";
export * from "./user/index.js";
export * from "./github-data/index.js";

View file

@ -1,76 +1,31 @@
// @ts-nocheck
import { desc, eq } from "drizzle-orm";
import { Loop } from "rivetkit/workflow";
import type {
CreateTaskInput,
AuditLogEvent,
HistoryQueryInput,
ListTasksInput,
SandboxProviderId,
RepoOverview,
RepoRecord,
StarSandboxAgentRepoInput,
StarSandboxAgentRepoResult,
SwitchResult,
TaskRecord,
TaskSummary,
TaskWorkspaceChangeModelInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceDiffInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSelectInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceSessionInput,
TaskWorkspaceUpdateDraftInput,
WorkspaceRepositorySummary,
WorkspaceTaskSummary,
OrganizationEvent,
OrganizationGithubSummary,
OrganizationSummarySnapshot,
OrganizationUseInput,
} from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateAuditLog, getOrCreateGithubData, getTask as getTaskHandle, getOrCreateRepository, selfOrganization } from "../handles.js";
import { getOrCreateRepository } from "../handles.js";
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import { defaultSandboxProviderId } from "../../sandbox-config.js";
import { repoIdFromRemote } from "../../services/repo.js";
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
import { organizationProfile, repos } from "./db/schema.js";
import { agentTypeForModel } from "../task/workspace.js";
import { expectQueueResponse } from "../../services/queue.js";
import { organizationAppActions } from "./app-shell.js";
import { organizationAppActions } from "./actions/app.js";
import { organizationBetterAuthActions } from "./actions/better-auth.js";
import { organizationOnboardingActions } from "./actions/onboarding.js";
import { organizationGithubActions } from "./actions/github.js";
import { organizationShellActions } from "./actions/organization.js";
import { organizationTaskActions } from "./actions/tasks.js";
export { createTaskMutation } from "./actions/tasks.js";
interface OrganizationState {
organizationId: string;
}
interface GetTaskInput {
organizationId: string;
repoId?: string;
taskId: string;
}
interface TaskProxyActionInput extends GetTaskInput {
reason?: string;
}
interface RepoOverviewInput {
organizationId: string;
repoId: string;
}
const ORGANIZATION_QUEUE_NAMES = ["organization.command.createTask", "organization.command.syncGithubSession"] as const;
const SANDBOX_AGENT_REPO = "rivet-dev/sandbox-agent";
type OrganizationQueueName = (typeof ORGANIZATION_QUEUE_NAMES)[number];
export { ORGANIZATION_QUEUE_NAMES };
export function organizationWorkflowQueueName(name: OrganizationQueueName): OrganizationQueueName {
return name;
}
const ORGANIZATION_PROFILE_ROW_ID = 1;
function assertOrganization(c: { state: OrganizationState }, organizationId: string): void {
@ -79,28 +34,6 @@ function assertOrganization(c: { state: OrganizationState }, organizationId: str
}
}
async function collectAllTaskSummaries(c: any): Promise<TaskSummary[]> {
const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all();
const all: TaskSummary[] = [];
for (const row of repoRows) {
try {
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("organization", "failed collecting tasks for repo", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
all.sort((a, b) => b.updatedAt - a.updatedAt);
return all;
}
function repoLabelFromRemote(remoteUrl: string): string {
try {
const url = new URL(remoteUrl.startsWith("http") ? remoteUrl : `https://${remoteUrl}`);
@ -127,67 +60,30 @@ function buildRepoSummary(repoRow: { repoId: string; remoteUrl: string; updatedA
};
}
async function resolveRepositoryForTask(c: any, taskId: string, repoId?: string | null) {
if (repoId) {
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${repoId}`);
}
const repository = await getOrCreateRepository(c, c.state.organizationId, repoId, repoRow.remoteUrl);
return { repoId, repository };
}
const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all();
for (const row of repoRows) {
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl);
const summaries = await repository.listTaskSummaries({ includeArchived: true });
if (summaries.some((summary: TaskSummary) => summary.taskId === taskId)) {
return { repoId: row.repoId, repository };
}
}
throw new Error(`Unknown task: ${taskId}`);
}
async function reconcileWorkspaceProjection(c: any): Promise<OrganizationSummarySnapshot> {
const repoRows = await c.db
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })
.from(repos)
.orderBy(desc(repos.updatedAt))
.all();
const taskRows: WorkspaceTaskSummary[] = [];
for (const row of repoRows) {
try {
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl);
taskRows.push(...(await repository.listWorkspaceTaskSummaries({})));
} catch (error) {
logActorWarning("organization", "failed collecting repo during workspace reconciliation", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
taskRows.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
function buildGithubSummary(profile: any, importedRepoCount: number): OrganizationGithubSummary {
return {
organizationId: c.state.organizationId,
repos: repoRows.map((row) => buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
taskSummaries: taskRows,
connectedAccount: profile?.githubConnectedAccount ?? "",
installationStatus: profile?.githubInstallationStatus ?? "install_required",
syncStatus: profile?.githubSyncStatus ?? "pending",
importedRepoCount,
lastSyncLabel: profile?.githubLastSyncLabel ?? "Waiting for first import",
lastSyncAt: profile?.githubLastSyncAt ?? null,
lastWebhookAt: profile?.githubLastWebhookAt ?? null,
lastWebhookEvent: profile?.githubLastWebhookEvent ?? "",
syncGeneration: profile?.githubSyncGeneration ?? 0,
syncPhase: profile?.githubSyncPhase ?? null,
processedRepositoryCount: profile?.githubProcessedRepositoryCount ?? 0,
totalRepositoryCount: profile?.githubTotalRepositoryCount ?? 0,
};
}
async function requireWorkspaceTask(c: any, repoId: string, taskId: string) {
return getTaskHandle(c, c.state.organizationId, repoId, taskId);
}
/**
* Reads the organization sidebar snapshot by fanning out one level to the
* repository coordinators. Task summaries are repository-owned; organization
* only aggregates them.
*/
async function getOrganizationSummarySnapshot(c: any): Promise<OrganizationSummarySnapshot> {
const profile = await c.db.select().from(organizationProfile).where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID)).get();
const repoRows = await c.db
.select({
repoId: repos.repoId,
@ -200,7 +96,7 @@ async function getOrganizationSummarySnapshot(c: any): Promise<OrganizationSumma
const summaries: WorkspaceTaskSummary[] = [];
for (const row of repoRows) {
try {
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl);
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId);
summaries.push(...(await repository.listWorkspaceTaskSummaries({})));
} catch (error) {
logActorWarning("organization", "failed reading repository task projection", {
@ -214,98 +110,26 @@ async function getOrganizationSummarySnapshot(c: any): Promise<OrganizationSumma
return {
organizationId: c.state.organizationId,
github: buildGithubSummary(profile, repoRows.length),
repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
taskSummaries: summaries,
};
}
async function broadcastOrganizationSnapshot(c: any): Promise<void> {
export async function refreshOrganizationSnapshotMutation(c: any): Promise<void> {
c.broadcast("organizationUpdated", {
type: "organizationUpdated",
snapshot: await getOrganizationSummarySnapshot(c),
} satisfies OrganizationEvent);
}
async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskRecord> {
assertOrganization(c, input.organizationId);
const { config } = getActorRuntimeContext();
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();
if (!repoRow) {
throw new Error(`Unknown repo: ${repoId}`);
}
const remoteUrl = repoRow.remoteUrl;
const repository = await getOrCreateRepository(c, c.state.organizationId, repoId, remoteUrl);
const created = await repository.createTask({
task: input.task,
sandboxProviderId,
agentType: input.agentType ?? null,
explicitTitle: input.explicitTitle ?? null,
explicitBranchName: input.explicitBranchName ?? null,
onBranch: input.onBranch ?? null,
});
return created;
}
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) {
return Loop.continue(undefined);
}
try {
if (msg.name === "organization.command.createTask") {
const result = await loopCtx.step({
name: "organization-create-task",
timeout: 5 * 60_000,
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.syncGithubSession") {
await loopCtx.step({
name: "organization-sync-github-session",
timeout: 60_000,
run: async () => {
const { syncGithubOrganizations } = await import("./app-shell.js");
await syncGithubOrganizations(loopCtx, msg.body as { sessionId: string; accessToken: string });
},
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
} catch (error) {
const message = resolveErrorMessage(error);
logActorWarning("organization", "organization workflow command failed", {
queueName: msg.name,
error: message,
});
await msg.complete({ error: message }).catch((completeError: unknown) => {
logActorWarning("organization", "organization workflow failed completing error response", {
queueName: msg.name,
error: resolveErrorMessage(completeError),
});
});
}
return Loop.continue(undefined);
});
}
export const organizationActions = {
...organizationBetterAuthActions,
...organizationGithubActions,
...organizationOnboardingActions,
...organizationShellActions,
...organizationAppActions,
...organizationTaskActions,
async useOrganization(c: any, input: OrganizationUseInput): Promise<{ organizationId: string }> {
assertOrganization(c, input.organizationId);
return { organizationId: c.state.organizationId };
@ -334,381 +158,180 @@ export const organizationActions = {
}));
},
async createTask(c: any, input: CreateTaskInput): Promise<TaskRecord> {
const self = selfOrganization(c);
return expectQueueResponse<TaskRecord>(
await self.send(organizationWorkflowQueueName("organization.command.createTask"), input, {
wait: true,
timeout: 10_000,
}),
);
},
async starSandboxAgentRepo(c: any, input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult> {
async getOrganizationSummary(c: any, input: OrganizationUseInput): Promise<OrganizationSummarySnapshot> {
assertOrganization(c, input.organizationId);
const { driver } = getActorRuntimeContext();
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(),
};
return await getOrganizationSummarySnapshot(c);
},
};
async refreshOrganizationSnapshot(c: any): Promise<void> {
await broadcastOrganizationSnapshot(c);
export async function applyGithubRepositoryProjectionMutation(c: any, input: { repoId: string; remoteUrl: string }): Promise<void> {
const now = Date.now();
await c.db
.insert(repos)
.values({
repoId: input.repoId,
remoteUrl: input.remoteUrl,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: repos.repoId,
set: {
remoteUrl: input.remoteUrl,
updatedAt: now,
},
})
.run();
await refreshOrganizationSnapshotMutation(c);
}
export async function applyGithubDataProjectionMutation(
c: any,
input: {
connectedAccount: string;
installationStatus: string;
installationId: number | null;
syncStatus: string;
lastSyncLabel: string;
lastSyncAt: number | null;
syncGeneration: number;
syncPhase: string | null;
processedRepositoryCount: number;
totalRepositoryCount: number;
repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>;
},
): Promise<void> {
const existingRepos = await c.db.select({ repoId: repos.repoId }).from(repos).all();
const nextRepoIds = new Set<string>();
const now = Date.now();
async applyGithubRepositoryProjection(c: any, input: { repoId: string; remoteUrl: string }): Promise<void> {
const now = Date.now();
const existing = await c.db.select({ repoId: repos.repoId }).from(repos).where(eq(repos.repoId, input.repoId)).get();
const profile = await c.db
.select({ id: organizationProfile.id })
.from(organizationProfile)
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.get();
if (profile) {
await c.db
.update(organizationProfile)
.set({
githubConnectedAccount: input.connectedAccount,
githubInstallationStatus: input.installationStatus,
githubSyncStatus: input.syncStatus,
githubInstallationId: input.installationId,
githubLastSyncLabel: input.lastSyncLabel,
githubLastSyncAt: input.lastSyncAt,
githubSyncGeneration: input.syncGeneration,
githubSyncPhase: input.syncPhase,
githubProcessedRepositoryCount: input.processedRepositoryCount,
githubTotalRepositoryCount: input.totalRepositoryCount,
updatedAt: now,
})
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.run();
}
for (const repository of input.repositories) {
const repoId = repoIdFromRemote(repository.cloneUrl);
nextRepoIds.add(repoId);
await c.db
.insert(repos)
.values({
repoId: input.repoId,
remoteUrl: input.remoteUrl,
repoId,
remoteUrl: repository.cloneUrl,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: repos.repoId,
set: {
remoteUrl: input.remoteUrl,
remoteUrl: repository.cloneUrl,
updatedAt: now,
},
})
.run();
await broadcastOrganizationSnapshot(c);
},
}
async applyGithubDataProjection(
c: any,
input: {
connectedAccount: string;
installationStatus: string;
installationId: number | null;
syncStatus: string;
lastSyncLabel: string;
lastSyncAt: number | null;
repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>;
},
): Promise<void> {
const existingRepos = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt }).from(repos).all();
const existingById = new Map(existingRepos.map((repo) => [repo.repoId, repo]));
const nextRepoIds = new Set<string>();
const now = Date.now();
for (const repository of input.repositories) {
const repoId = repoIdFromRemote(repository.cloneUrl);
nextRepoIds.add(repoId);
await c.db
.insert(repos)
.values({
repoId,
remoteUrl: repository.cloneUrl,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: repos.repoId,
set: {
remoteUrl: repository.cloneUrl,
updatedAt: now,
},
})
.run();
await broadcastOrganizationSnapshot(c);
for (const repo of existingRepos) {
if (nextRepoIds.has(repo.repoId)) {
continue;
}
await c.db.delete(repos).where(eq(repos.repoId, repo.repoId)).run();
}
for (const repo of existingRepos) {
if (nextRepoIds.has(repo.repoId)) {
continue;
}
await c.db.delete(repos).where(eq(repos.repoId, repo.repoId)).run();
await broadcastOrganizationSnapshot(c);
}
await refreshOrganizationSnapshotMutation(c);
}
const profile = await c.db
.select({ id: organizationProfile.id })
.from(organizationProfile)
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.get();
if (profile) {
await c.db
.update(organizationProfile)
.set({
githubConnectedAccount: input.connectedAccount,
githubInstallationStatus: input.installationStatus,
githubSyncStatus: input.syncStatus,
githubInstallationId: input.installationId,
githubLastSyncLabel: input.lastSyncLabel,
githubLastSyncAt: input.lastSyncAt,
updatedAt: now,
})
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.run();
}
export async function applyGithubSyncProgressMutation(
c: any,
input: {
connectedAccount: string;
installationStatus: string;
installationId: number | null;
syncStatus: string;
lastSyncLabel: string;
lastSyncAt: number | null;
syncGeneration: number;
syncPhase: string | null;
processedRepositoryCount: number;
totalRepositoryCount: number;
},
): Promise<void> {
const profile = await c.db
.select({ id: organizationProfile.id })
.from(organizationProfile)
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.get();
if (!profile) {
return;
}
async recordGithubWebhookReceipt(
c: any,
input: {
organizationId: string;
event: string;
action?: string | null;
receivedAt?: number;
},
): Promise<void> {
assertOrganization(c, input.organizationId);
await c.db
.update(organizationProfile)
.set({
githubConnectedAccount: input.connectedAccount,
githubInstallationStatus: input.installationStatus,
githubSyncStatus: input.syncStatus,
githubInstallationId: input.installationId,
githubLastSyncLabel: input.lastSyncLabel,
githubLastSyncAt: input.lastSyncAt,
githubSyncGeneration: input.syncGeneration,
githubSyncPhase: input.syncPhase,
githubProcessedRepositoryCount: input.processedRepositoryCount,
githubTotalRepositoryCount: input.totalRepositoryCount,
updatedAt: Date.now(),
})
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.run();
const profile = await c.db
.select({ id: organizationProfile.id })
.from(organizationProfile)
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.get();
if (!profile) {
return;
}
await refreshOrganizationSnapshotMutation(c);
}
await c.db
.update(organizationProfile)
.set({
githubLastWebhookAt: input.receivedAt ?? Date.now(),
githubLastWebhookEvent: input.action ? `${input.event}.${input.action}` : input.event,
})
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.run();
export async function recordGithubWebhookReceiptMutation(
c: any,
input: {
organizationId: string;
event: string;
action?: string | null;
receivedAt?: number;
},
): Promise<void> {
assertOrganization(c, input.organizationId);
async getOrganizationSummary(c: any, input: OrganizationUseInput): Promise<OrganizationSummarySnapshot> {
assertOrganization(c, input.organizationId);
return await getOrganizationSummarySnapshot(c);
},
const profile = await c.db
.select({ id: organizationProfile.id })
.from(organizationProfile)
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.get();
if (!profile) {
return;
}
async adminReconcileWorkspaceState(c: any, input: OrganizationUseInput): Promise<OrganizationSummarySnapshot> {
assertOrganization(c, input.organizationId);
return await reconcileWorkspaceProjection(c);
},
async createWorkspaceTask(c: any, input: TaskWorkspaceCreateTaskInput): 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 requireWorkspaceTask(c, input.repoId, created.taskId);
await task.createWorkspaceSessionAndSend({
model: input.model,
text: input.task,
});
return { taskId: created.taskId };
},
async markWorkspaceUnread(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.markWorkspaceUnread({});
},
async renameWorkspaceTask(c: any, input: TaskWorkspaceRenameInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.renameWorkspaceTask(input);
},
async createWorkspaceSession(c: any, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
return await task.createWorkspaceSession({ ...(input.model ? { model: input.model } : {}) });
},
async renameWorkspaceSession(c: any, input: TaskWorkspaceRenameSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.renameWorkspaceSession(input);
},
async setWorkspaceSessionUnread(c: any, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.setWorkspaceSessionUnread(input);
},
async updateWorkspaceDraft(c: any, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.updateWorkspaceDraft(input);
},
async changeWorkspaceModel(c: any, input: TaskWorkspaceChangeModelInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.changeWorkspaceModel(input);
},
async sendWorkspaceMessage(c: any, input: TaskWorkspaceSendMessageInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.sendWorkspaceMessage(input);
},
async stopWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.stopWorkspaceSession(input);
},
async closeWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.closeWorkspaceSession(input);
},
async publishWorkspacePr(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.publishWorkspacePr({});
},
async revertWorkspaceFile(c: any, input: TaskWorkspaceDiffInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.revertWorkspaceFile(input);
},
async adminReloadGithubOrganization(c: any): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).adminReloadOrganization({});
},
async adminReloadGithubPullRequests(c: any): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).adminReloadAllPullRequests({});
},
async adminReloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).reloadRepository(input);
},
async adminReloadGithubPullRequest(c: any, input: { repoId: string; prNumber: number }): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).reloadPullRequest(input);
},
async listTasks(c: any, input: ListTasksInput): Promise<TaskSummary[]> {
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();
if (!repoRow) {
throw new Error(`Unknown repo: ${input.repoId}`);
}
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> {
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 repository = await getOrCreateRepository(c, c.state.organizationId, input.repoId, repoRow.remoteUrl);
return await repository.getRepoOverview({});
},
async switchTask(c: any, input: { repoId?: string; taskId: string }): Promise<SwitchResult> {
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
const record = await h.get();
const switched = await h.switch();
return {
organizationId: c.state.organizationId,
taskId: input.taskId,
sandboxProviderId: record.sandboxProviderId,
switchTarget: switched.switchTarget,
};
},
async auditLog(c: any, input: HistoryQueryInput): Promise<AuditLogEvent[]> {
assertOrganization(c, input.organizationId);
const limit = input.limit ?? 20;
const repoRows = await c.db.select({ repoId: repos.repoId }).from(repos).all();
const allEvents: AuditLogEvent[] = [];
for (const row of repoRows) {
try {
const auditLog = await getOrCreateAuditLog(c, c.state.organizationId, row.repoId);
const items = await auditLog.list({
branch: input.branch,
taskId: input.taskId,
limit,
});
allEvents.push(...items);
} catch (error) {
logActorWarning("organization", "audit log lookup failed for repo", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
allEvents.sort((a, b) => b.createdAt - a.createdAt);
return allEvents.slice(0, limit);
},
async getTask(c: any, input: GetTaskInput): Promise<TaskRecord> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
return await getTaskHandle(c, c.state.organizationId, repoId, input.taskId).get();
},
async attachTask(c: any, input: TaskProxyActionInput): Promise<{ target: string; sessionId: string | null }> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
return await h.attach({ reason: input.reason });
},
async pushTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.push({ reason: input.reason });
},
async syncTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.sync({ reason: input.reason });
},
async mergeTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.merge({ reason: input.reason });
},
async archiveTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.archive({ reason: input.reason });
},
async killTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.kill({ reason: input.reason });
},
};
await c.db
.update(organizationProfile)
.set({
githubLastWebhookAt: input.receivedAt ?? Date.now(),
githubLastWebhookEvent: input.action ? `${input.event}.${input.action}` : input.event,
})
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.run();
}

View file

@ -0,0 +1 @@
export { organizationAppActions } from "../app-shell.js";

View file

@ -0,0 +1,323 @@
import {
and,
asc,
count as sqlCount,
desc,
eq,
gt,
gte,
inArray,
isNotNull,
isNull,
like,
lt,
lte,
ne,
notInArray,
or,
} from "drizzle-orm";
import { authAccountIndex, authEmailIndex, authSessionIndex, authVerification } from "../db/schema.js";
import { APP_SHELL_ORGANIZATION_ID } from "../constants.js";
function assertAppOrganization(c: any): void {
if (c.state.organizationId !== APP_SHELL_ORGANIZATION_ID) {
throw new Error(`App shell action requires organization ${APP_SHELL_ORGANIZATION_ID}, got ${c.state.organizationId}`);
}
}
function organizationAuthColumn(table: any, field: string): any {
const column = table[field];
if (!column) {
throw new Error(`Unknown auth table field: ${field}`);
}
return column;
}
function normalizeAuthValue(value: unknown): unknown {
if (value instanceof Date) {
return value.getTime();
}
if (Array.isArray(value)) {
return value.map((entry) => normalizeAuthValue(entry));
}
return value;
}
function organizationAuthClause(table: any, clause: { field: string; value: unknown; operator?: string }): any {
const column = organizationAuthColumn(table, clause.field);
const value = normalizeAuthValue(clause.value);
switch (clause.operator) {
case "ne":
return value === null ? isNotNull(column) : ne(column, value as any);
case "lt":
return lt(column, value as any);
case "lte":
return lte(column, value as any);
case "gt":
return gt(column, value as any);
case "gte":
return gte(column, value as any);
case "in":
return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "not_in":
return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "contains":
return like(column, `%${String(value ?? "")}%`);
case "starts_with":
return like(column, `${String(value ?? "")}%`);
case "ends_with":
return like(column, `%${String(value ?? "")}`);
case "eq":
default:
return value === null ? isNull(column) : eq(column, value as any);
}
}
function organizationBetterAuthWhere(table: any, clauses: any[] | undefined): any {
if (!clauses || clauses.length === 0) {
return undefined;
}
let expr = organizationAuthClause(table, clauses[0]);
for (const clause of clauses.slice(1)) {
const next = organizationAuthClause(table, clause);
expr = clause.connector === "OR" ? or(expr, next) : and(expr, next);
}
return expr;
}
export async function betterAuthUpsertSessionIndexMutation(c: any, input: { sessionId: string; sessionToken: string; userId: string }) {
assertAppOrganization(c);
const now = Date.now();
await c.db
.insert(authSessionIndex)
.values({
sessionId: input.sessionId,
sessionToken: input.sessionToken,
userId: input.userId,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: authSessionIndex.sessionId,
set: {
sessionToken: input.sessionToken,
userId: input.userId,
updatedAt: now,
},
})
.run();
return await c.db.select().from(authSessionIndex).where(eq(authSessionIndex.sessionId, input.sessionId)).get();
}
export async function betterAuthDeleteSessionIndexMutation(c: any, input: { sessionId?: string; sessionToken?: string }) {
assertAppOrganization(c);
const clauses = [
...(input.sessionId ? [{ field: "sessionId", value: input.sessionId }] : []),
...(input.sessionToken ? [{ field: "sessionToken", value: input.sessionToken }] : []),
];
if (clauses.length === 0) {
return;
}
const predicate = organizationBetterAuthWhere(authSessionIndex, clauses);
await c.db.delete(authSessionIndex).where(predicate!).run();
}
export async function betterAuthUpsertEmailIndexMutation(c: any, input: { email: string; userId: string }) {
assertAppOrganization(c);
const now = Date.now();
await c.db
.insert(authEmailIndex)
.values({
email: input.email,
userId: input.userId,
updatedAt: now,
})
.onConflictDoUpdate({
target: authEmailIndex.email,
set: {
userId: input.userId,
updatedAt: now,
},
})
.run();
return await c.db.select().from(authEmailIndex).where(eq(authEmailIndex.email, input.email)).get();
}
export async function betterAuthDeleteEmailIndexMutation(c: any, input: { email: string }) {
assertAppOrganization(c);
await c.db.delete(authEmailIndex).where(eq(authEmailIndex.email, input.email)).run();
}
export async function betterAuthUpsertAccountIndexMutation(
c: any,
input: { id: string; providerId: string; accountId: string; userId: string },
) {
assertAppOrganization(c);
const now = Date.now();
await c.db
.insert(authAccountIndex)
.values({
id: input.id,
providerId: input.providerId,
accountId: input.accountId,
userId: input.userId,
updatedAt: now,
})
.onConflictDoUpdate({
target: authAccountIndex.id,
set: {
providerId: input.providerId,
accountId: input.accountId,
userId: input.userId,
updatedAt: now,
},
})
.run();
return await c.db.select().from(authAccountIndex).where(eq(authAccountIndex.id, input.id)).get();
}
export async function betterAuthDeleteAccountIndexMutation(c: any, input: { id?: string; providerId?: string; accountId?: string }) {
assertAppOrganization(c);
if (input.id) {
await c.db.delete(authAccountIndex).where(eq(authAccountIndex.id, input.id)).run();
return;
}
if (input.providerId && input.accountId) {
await c.db
.delete(authAccountIndex)
.where(and(eq(authAccountIndex.providerId, input.providerId), eq(authAccountIndex.accountId, input.accountId)))
.run();
}
}
export async function betterAuthCreateVerificationMutation(c: any, input: { data: Record<string, unknown> }) {
assertAppOrganization(c);
await c.db.insert(authVerification).values(input.data as any).run();
return await c.db.select().from(authVerification).where(eq(authVerification.id, input.data.id as string)).get();
}
export async function betterAuthUpdateVerificationMutation(c: any, input: { where: any[]; update: Record<string, unknown> }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
if (!predicate) {
return null;
}
await c.db.update(authVerification).set(input.update as any).where(predicate).run();
return await c.db.select().from(authVerification).where(predicate).get();
}
export async function betterAuthUpdateManyVerificationMutation(c: any, input: { where: any[]; update: Record<string, unknown> }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
if (!predicate) {
return 0;
}
await c.db.update(authVerification).set(input.update as any).where(predicate).run();
const row = await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get();
return row?.value ?? 0;
}
export async function betterAuthDeleteVerificationMutation(c: any, input: { where: any[] }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
if (!predicate) {
return;
}
await c.db.delete(authVerification).where(predicate).run();
}
export async function betterAuthDeleteManyVerificationMutation(c: any, input: { where: any[] }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
if (!predicate) {
return 0;
}
const rows = await c.db.select().from(authVerification).where(predicate).all();
await c.db.delete(authVerification).where(predicate).run();
return rows.length;
}
export const organizationBetterAuthActions = {
async betterAuthFindSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) {
assertAppOrganization(c);
const clauses = [
...(input.sessionId ? [{ field: "sessionId", value: input.sessionId }] : []),
...(input.sessionToken ? [{ field: "sessionToken", value: input.sessionToken }] : []),
];
if (clauses.length === 0) {
return null;
}
const predicate = organizationBetterAuthWhere(authSessionIndex, clauses);
return await c.db.select().from(authSessionIndex).where(predicate!).get();
},
async betterAuthFindEmailIndex(c: any, input: { email: string }) {
assertAppOrganization(c);
return await c.db.select().from(authEmailIndex).where(eq(authEmailIndex.email, input.email)).get();
},
async betterAuthFindAccountIndex(c: any, input: { id?: string; providerId?: string; accountId?: string }) {
assertAppOrganization(c);
if (input.id) {
return await c.db.select().from(authAccountIndex).where(eq(authAccountIndex.id, input.id)).get();
}
if (!input.providerId || !input.accountId) {
return null;
}
return await c.db
.select()
.from(authAccountIndex)
.where(and(eq(authAccountIndex.providerId, input.providerId), eq(authAccountIndex.accountId, input.accountId)))
.get();
},
async betterAuthFindOneVerification(c: any, input: { where: any[] }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
return predicate ? await c.db.select().from(authVerification).where(predicate).get() : null;
},
async betterAuthFindManyVerification(c: any, input: { where?: any[]; limit?: number; sortBy?: any; offset?: number }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
let query = c.db.select().from(authVerification);
if (predicate) {
query = query.where(predicate);
}
if (input.sortBy?.field) {
const column = organizationAuthColumn(authVerification, input.sortBy.field);
query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column));
}
if (typeof input.limit === "number") {
query = query.limit(input.limit);
}
if (typeof input.offset === "number") {
query = query.offset(input.offset);
}
return await query.all();
},
async betterAuthCountVerification(c: any, input: { where?: any[] }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
const row = predicate
? await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get()
: await c.db.select({ value: sqlCount() }).from(authVerification).get();
return row?.value ?? 0;
},
};

View file

@ -0,0 +1,91 @@
import { desc } from "drizzle-orm";
import type { FoundryAppSnapshot } from "@sandbox-agent/foundry-shared";
import { getOrCreateGithubData, getOrCreateOrganization } from "../../handles.js";
import { authSessionIndex } from "../db/schema.js";
import { githubDataWorkflowQueueName } from "../../github-data/workflow.js";
import {
assertAppOrganization,
buildAppSnapshot,
requireEligibleOrganization,
requireSignedInSession,
} from "../app-shell.js";
import { getBetterAuthService } from "../../../services/better-auth.js";
import { expectQueueResponse } from "../../../services/queue.js";
import { organizationWorkflowQueueName } from "../queues.js";
export const organizationGithubActions = {
async resolveAppGithubToken(
c: any,
input: { organizationId: string; requireRepoScope?: boolean },
): Promise<{ accessToken: string; scopes: string[] } | null> {
assertAppOrganization(c);
const auth = getBetterAuthService();
const rows = await c.db.select().from(authSessionIndex).orderBy(desc(authSessionIndex.updatedAt)).all();
for (const row of rows) {
const authState = await auth.getAuthState(row.sessionId);
if (authState?.sessionState?.activeOrganizationId !== input.organizationId) {
continue;
}
const token = await auth.getAccessTokenForSession(row.sessionId);
if (!token?.accessToken) {
continue;
}
const scopes = token.scopes;
if (input.requireRepoScope !== false && scopes.length > 0 && !scopes.some((scope) => scope === "repo" || scope.startsWith("repo:"))) {
continue;
}
return {
accessToken: token.accessToken,
scopes,
};
}
return null;
},
async triggerAppRepoImport(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const githubData = await getOrCreateGithubData(c, input.organizationId);
const summary = await githubData.getSummary({});
if (summary.syncStatus === "syncing") {
return await buildAppSnapshot(c, input.sessionId);
}
const organizationHandle = await getOrCreateOrganization(c, input.organizationId);
await expectQueueResponse<{ ok: true }>(
await organizationHandle.send(
organizationWorkflowQueueName("organization.command.shell.sync_started.mark"),
{ label: "Importing repository catalog..." },
{ wait: true, timeout: 10_000 },
),
);
await expectQueueResponse<{ ok: true }>(
await organizationHandle.send(organizationWorkflowQueueName("organization.command.snapshot.broadcast"), {}, { wait: true, timeout: 10_000 }),
);
await githubData.send("githubData.command.syncRepos", { label: "Importing repository catalog..." }, { wait: false });
return await buildAppSnapshot(c, input.sessionId);
},
async adminReloadGithubOrganization(c: any): Promise<void> {
const githubData = await getOrCreateGithubData(c, c.state.organizationId);
await expectQueueResponse<{ ok: true }>(
await githubData.send(githubDataWorkflowQueueName("githubData.command.syncRepos"), { label: "Reloading GitHub organization..." }, { wait: true, timeout: 10_000 }),
);
},
async adminReloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
const githubData = await getOrCreateGithubData(c, c.state.organizationId);
await expectQueueResponse<unknown>(
await githubData.send(githubDataWorkflowQueueName("githubData.command.reloadRepository"), input, { wait: true, timeout: 10_000 }),
);
},
};

View file

@ -0,0 +1,82 @@
import { randomUUID } from "node:crypto";
import type { FoundryAppSnapshot, StarSandboxAgentRepoInput, StarSandboxAgentRepoResult } from "@sandbox-agent/foundry-shared";
import { getOrCreateGithubData, getOrCreateOrganization } from "../../handles.js";
import {
assertAppOrganization,
buildAppSnapshot,
getOrganizationState,
requireEligibleOrganization,
requireSignedInSession,
} from "../app-shell.js";
import { getBetterAuthService } from "../../../services/better-auth.js";
import { getActorRuntimeContext } from "../../context.js";
import { resolveOrganizationGithubAuth } from "../../../services/github-auth.js";
const SANDBOX_AGENT_REPO = "rivet-dev/sandbox-agent";
export const organizationOnboardingActions = {
async skipAppStarterRepo(c: any, input: { sessionId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
await getBetterAuthService().upsertUserProfile(session.authUserId, {
starterRepoStatus: "skipped",
starterRepoSkippedAt: Date.now(),
starterRepoStarredAt: null,
});
return await buildAppSnapshot(c, input.sessionId);
},
async starAppStarterRepo(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const organization = await getOrCreateOrganization(c, input.organizationId);
await organization.starSandboxAgentRepo({
organizationId: input.organizationId,
});
await getBetterAuthService().upsertUserProfile(session.authUserId, {
starterRepoStatus: "starred",
starterRepoStarredAt: Date.now(),
starterRepoSkippedAt: null,
});
return await buildAppSnapshot(c, input.sessionId);
},
async selectAppOrganization(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
await getBetterAuthService().setActiveOrganization(input.sessionId, input.organizationId);
await getOrCreateGithubData(c, input.organizationId);
return await buildAppSnapshot(c, input.sessionId);
},
async beginAppGithubInstall(c: any, input: { sessionId: string; organizationId: string }): Promise<{ url: string }> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const { appShell } = getActorRuntimeContext();
const organizationHandle = await getOrCreateOrganization(c, input.organizationId);
const organizationState = await getOrganizationState(organizationHandle);
if (organizationState.snapshot.kind !== "organization") {
return {
url: `${appShell.appUrl}/organizations/${input.organizationId}`,
};
}
return {
url: await appShell.github.buildInstallationUrl(organizationState.githubLogin, randomUUID()),
};
},
async starSandboxAgentRepo(c: any, input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult> {
const { driver } = getActorRuntimeContext();
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(),
};
},
};

View file

@ -0,0 +1,61 @@
import type { FoundryAppSnapshot, UpdateFoundryOrganizationProfileInput, WorkspaceModelId } from "@sandbox-agent/foundry-shared";
import { getBetterAuthService } from "../../../services/better-auth.js";
import { getOrCreateOrganization } from "../../handles.js";
import { expectQueueResponse } from "../../../services/queue.js";
import {
assertAppOrganization,
assertOrganizationShell,
buildAppSnapshot,
buildOrganizationState,
buildOrganizationStateIfInitialized,
requireEligibleOrganization,
requireSignedInSession,
} from "../app-shell.js";
import { organizationWorkflowQueueName } from "../queues.js";
export const organizationShellActions = {
async getAppSnapshot(c: any, input: { sessionId: string }): Promise<FoundryAppSnapshot> {
return await buildAppSnapshot(c, input.sessionId);
},
async setAppDefaultModel(c: any, input: { sessionId: string; defaultModel: WorkspaceModelId }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
await getBetterAuthService().upsertUserProfile(session.authUserId, {
defaultModel: input.defaultModel,
});
return await buildAppSnapshot(c, input.sessionId);
},
async updateAppOrganizationProfile(
c: any,
input: { sessionId: string; organizationId: string } & UpdateFoundryOrganizationProfileInput,
): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const organization = await getOrCreateOrganization(c, input.organizationId);
await expectQueueResponse<{ ok: true }>(
await organization.send(
organizationWorkflowQueueName("organization.command.shell.profile.update"),
{
displayName: input.displayName,
slug: input.slug,
primaryDomain: input.primaryDomain,
},
{ wait: true, timeout: 10_000 },
),
);
return await buildAppSnapshot(c, input.sessionId);
},
async getOrganizationShellState(c: any): Promise<any> {
assertOrganizationShell(c);
return await buildOrganizationState(c);
},
async getOrganizationShellStateIfInitialized(c: any): Promise<any | null> {
assertOrganizationShell(c);
return await buildOrganizationStateIfInitialized(c);
},
};

View file

@ -0,0 +1,387 @@
// @ts-nocheck
import { desc, eq } from "drizzle-orm";
import type {
AuditLogEvent,
CreateTaskInput,
HistoryQueryInput,
ListTasksInput,
RepoOverview,
SwitchResult,
TaskRecord,
TaskSummary,
TaskWorkspaceChangeModelInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceDiffInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSelectInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceSessionInput,
TaskWorkspaceUpdateDraftInput,
} from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../../context.js";
import { getOrCreateAuditLog, getOrCreateRepository, getTask as getTaskHandle, selfOrganization } from "../../handles.js";
import { defaultSandboxProviderId } from "../../../sandbox-config.js";
import { expectQueueResponse } from "../../../services/queue.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { repositoryWorkflowQueueName } from "../../repository/workflow.js";
import { taskWorkflowQueueName } from "../../task/workflow/index.js";
import { repos } from "../db/schema.js";
import { organizationWorkflowQueueName } from "../queues.js";
function assertOrganization(c: { state: { organizationId: string } }, organizationId: string): void {
if (organizationId !== c.state.organizationId) {
throw new Error(`Organization actor mismatch: actor=${c.state.organizationId} command=${organizationId}`);
}
}
async function requireRepositoryForTask(c: any, repoId: string) {
const repoRow = await c.db.select({ repoId: repos.repoId }).from(repos).where(eq(repos.repoId, repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${repoId}`);
}
return await getOrCreateRepository(c, c.state.organizationId, repoId);
}
async function requireWorkspaceTask(c: any, repoId: string, taskId: string) {
return getTaskHandle(c, c.state.organizationId, repoId, taskId);
}
async function collectAllTaskSummaries(c: any): Promise<TaskSummary[]> {
const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all();
const all: TaskSummary[] = [];
for (const row of repoRows) {
try {
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId);
const snapshot = await repository.listTaskSummaries({ includeArchived: true });
all.push(...snapshot);
} catch (error) {
logActorWarning("organization", "failed collecting tasks for repo", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
all.sort((a, b) => b.updatedAt - a.updatedAt);
return all;
}
interface GetTaskInput {
organizationId: string;
repoId: string;
taskId: string;
}
interface TaskProxyActionInput extends GetTaskInput {
reason?: string;
}
interface RepoOverviewInput {
organizationId: string;
repoId: string;
}
export async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskRecord> {
assertOrganization(c, input.organizationId);
const { config } = getActorRuntimeContext();
const sandboxProviderId = input.sandboxProviderId ?? defaultSandboxProviderId(config);
await requireRepositoryForTask(c, input.repoId);
const repository = await getOrCreateRepository(c, c.state.organizationId, input.repoId);
return expectQueueResponse<TaskRecord>(
await repository.send(
repositoryWorkflowQueueName("repository.command.createTask"),
{
task: input.task,
sandboxProviderId,
explicitTitle: input.explicitTitle ?? null,
explicitBranchName: input.explicitBranchName ?? null,
onBranch: input.onBranch ?? null,
},
{
wait: true,
timeout: 10_000,
},
),
);
}
export const organizationTaskActions = {
async createTask(c: any, input: CreateTaskInput): Promise<TaskRecord> {
const self = selfOrganization(c);
return expectQueueResponse<TaskRecord>(
await self.send(organizationWorkflowQueueName("organization.command.createTask"), input, {
wait: true,
timeout: 10_000,
}),
);
},
async createWorkspaceTask(c: any, input: TaskWorkspaceCreateTaskInput): Promise<{ taskId: string; sessionId?: string }> {
const created = await organizationTaskActions.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 } : {}),
});
const task = await requireWorkspaceTask(c, input.repoId, created.taskId);
await task.send(
taskWorkflowQueueName("task.command.workspace.create_session_and_send"),
{
model: input.model,
text: input.task,
authSessionId: input.authSessionId,
},
{ wait: false },
);
return { taskId: created.taskId };
},
async markWorkspaceUnread(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(taskWorkflowQueueName("task.command.workspace.mark_unread"), { authSessionId: input.authSessionId }, { wait: true, timeout: 10_000 }),
);
},
async renameWorkspaceTask(c: any, input: TaskWorkspaceRenameInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(taskWorkflowQueueName("task.command.workspace.rename_task"), { value: input.value }, { wait: true, timeout: 20_000 }),
);
},
async createWorkspaceSession(c: any, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
return await expectQueueResponse<{ sessionId: string }>(
await task.send(
taskWorkflowQueueName("task.command.workspace.create_session"),
{
...(input.model ? { model: input.model } : {}),
...(input.authSessionId ? { authSessionId: input.authSessionId } : {}),
},
{ wait: true, timeout: 10_000 },
),
);
},
async renameWorkspaceSession(c: any, input: TaskWorkspaceRenameSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(
taskWorkflowQueueName("task.command.workspace.rename_session"),
{ sessionId: input.sessionId, title: input.title, authSessionId: input.authSessionId },
{ wait: true, timeout: 10_000 },
),
);
},
async selectWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(
taskWorkflowQueueName("task.command.workspace.select_session"),
{ sessionId: input.sessionId, authSessionId: input.authSessionId },
{ wait: true, timeout: 10_000 },
),
);
},
async setWorkspaceSessionUnread(c: any, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(
taskWorkflowQueueName("task.command.workspace.set_session_unread"),
{ sessionId: input.sessionId, unread: input.unread, authSessionId: input.authSessionId },
{ wait: true, timeout: 10_000 },
),
);
},
async updateWorkspaceDraft(c: any, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.send(
taskWorkflowQueueName("task.command.workspace.update_draft"),
{
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
authSessionId: input.authSessionId,
},
{ wait: false },
);
},
async changeWorkspaceModel(c: any, input: TaskWorkspaceChangeModelInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(
taskWorkflowQueueName("task.command.workspace.change_model"),
{ sessionId: input.sessionId, model: input.model, authSessionId: input.authSessionId },
{ wait: true, timeout: 10_000 },
),
);
},
async sendWorkspaceMessage(c: any, input: TaskWorkspaceSendMessageInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.send(
taskWorkflowQueueName("task.command.workspace.send_message"),
{
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
authSessionId: input.authSessionId,
},
{ wait: false },
);
},
async stopWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.send(
taskWorkflowQueueName("task.command.workspace.stop_session"),
{ sessionId: input.sessionId, authSessionId: input.authSessionId },
{ wait: false },
);
},
async closeWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.send(
taskWorkflowQueueName("task.command.workspace.close_session"),
{ sessionId: input.sessionId, authSessionId: input.authSessionId },
{ wait: false },
);
},
async publishWorkspacePr(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.send(taskWorkflowQueueName("task.command.workspace.publish_pr"), {}, { wait: false });
},
async revertWorkspaceFile(c: any, input: TaskWorkspaceDiffInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.send(taskWorkflowQueueName("task.command.workspace.revert_file"), input, { wait: false });
},
async getRepoOverview(c: any, input: RepoOverviewInput): Promise<RepoOverview> {
assertOrganization(c, input.organizationId);
const repository = await requireRepositoryForTask(c, input.repoId);
return await repository.getRepoOverview({});
},
async listTasks(c: any, input: ListTasksInput): Promise<TaskSummary[]> {
assertOrganization(c, input.organizationId);
if (input.repoId) {
const repository = await requireRepositoryForTask(c, input.repoId);
return await repository.listTaskSummaries({ includeArchived: true });
}
return await collectAllTaskSummaries(c);
},
async switchTask(c: any, input: { repoId: string; taskId: string }): Promise<SwitchResult> {
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
const record = await h.get();
const switched = await expectQueueResponse<{ switchTarget: string }>(
await h.send(taskWorkflowQueueName("task.command.switch"), {}, { wait: true, timeout: 10_000 }),
);
return {
organizationId: c.state.organizationId,
taskId: input.taskId,
sandboxProviderId: record.sandboxProviderId,
switchTarget: switched.switchTarget,
};
},
async auditLog(c: any, input: HistoryQueryInput): Promise<AuditLogEvent[]> {
assertOrganization(c, input.organizationId);
const limit = input.limit ?? 20;
const repoRows = await c.db.select({ repoId: repos.repoId }).from(repos).orderBy(desc(repos.updatedAt)).all();
const allEvents: AuditLogEvent[] = [];
for (const row of repoRows) {
try {
const auditLog = await getOrCreateAuditLog(c, c.state.organizationId, row.repoId);
const items = await auditLog.list({
branch: input.branch,
taskId: input.taskId,
limit,
});
allEvents.push(...items);
} catch (error) {
logActorWarning("organization", "audit log lookup failed for repo", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
allEvents.sort((a, b) => b.createdAt - a.createdAt);
return allEvents.slice(0, limit);
},
async getTask(c: any, input: GetTaskInput): Promise<TaskRecord> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
return await getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId).get();
},
async attachTask(c: any, input: TaskProxyActionInput): Promise<{ target: string; sessionId: string | null }> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
return await expectQueueResponse<{ target: string; sessionId: string | null }>(
await h.send(taskWorkflowQueueName("task.command.attach"), { reason: input.reason }, { wait: true, timeout: 10_000 }),
);
},
async pushTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
await h.send(taskWorkflowQueueName("task.command.push"), { reason: input.reason }, { wait: false });
},
async syncTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
await h.send(taskWorkflowQueueName("task.command.sync"), { reason: input.reason }, { wait: false });
},
async mergeTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
await h.send(taskWorkflowQueueName("task.command.merge"), { reason: input.reason }, { wait: false });
},
async archiveTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
await h.send(taskWorkflowQueueName("task.command.archive"), { reason: input.reason }, { wait: false });
},
async killTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
await h.send(taskWorkflowQueueName("task.command.kill"), { reason: input.reason }, { wait: false });
},
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
export const APP_SHELL_ORGANIZATION_ID = "app";

View file

@ -56,6 +56,10 @@ CREATE TABLE `organization_profile` (
`github_last_sync_at` integer,
`github_last_webhook_at` integer,
`github_last_webhook_event` text,
`github_sync_generation` integer NOT NULL,
`github_sync_phase` text,
`github_processed_repository_count` integer NOT NULL,
`github_total_repository_count` integer NOT NULL,
`stripe_customer_id` text,
`stripe_subscription_id` text,
`stripe_price_id` text,
@ -86,8 +90,3 @@ CREATE TABLE `stripe_lookup` (
`organization_id` text NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `task_lookup` (
`task_id` text PRIMARY KEY NOT NULL,
`repo_id` text NOT NULL
);

View file

@ -373,6 +373,34 @@
"notNull": false,
"autoincrement": false
},
"github_sync_generation": {
"name": "github_sync_generation",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"github_sync_phase": {
"name": "github_sync_phase",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_processed_repository_count": {
"name": "github_processed_repository_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"github_total_repository_count": {
"name": "github_total_repository_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stripe_customer_id": {
"name": "stripe_customer_id",
"type": "text",
@ -549,30 +577,6 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_lookup": {
"name": "task_lookup",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"repo_id": {
"name": "repo_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},

View file

@ -10,6 +10,12 @@ const journal = {
tag: "0000_melted_viper",
breakpoints: true,
},
{
idx: 1,
when: 1773907201000,
tag: "0001_github_sync_progress",
breakpoints: true,
},
],
} as const;
@ -104,6 +110,14 @@ CREATE TABLE \`stripe_lookup\` (
\`organization_id\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0001: `ALTER TABLE \`organization_profile\` ADD \`github_sync_generation\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`organization_profile\` ADD \`github_sync_phase\` text;
--> statement-breakpoint
ALTER TABLE \`organization_profile\` ADD \`github_processed_repository_count\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`organization_profile\` ADD \`github_total_repository_count\` integer NOT NULL DEFAULT 0;
`,
} as const,
};

View file

@ -36,6 +36,10 @@ export const organizationProfile = sqliteTable(
githubLastSyncAt: integer("github_last_sync_at"),
githubLastWebhookAt: integer("github_last_webhook_at"),
githubLastWebhookEvent: text("github_last_webhook_event"),
githubSyncGeneration: integer("github_sync_generation").notNull(),
githubSyncPhase: text("github_sync_phase"),
githubProcessedRepositoryCount: integer("github_processed_repository_count").notNull(),
githubTotalRepositoryCount: integer("github_total_repository_count").notNull(),
stripeCustomerId: text("stripe_customer_id"),
stripeSubscriptionId: text("stripe_subscription_id"),
stripePriceId: text("stripe_price_id"),

View file

@ -1,7 +1,9 @@
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";
import { organizationActions } from "./actions.js";
import { ORGANIZATION_QUEUE_NAMES } from "./queues.js";
import { runOrganizationWorkflow } from "./workflow.js";
export const organization = actor({
db: organizationDb,

View file

@ -0,0 +1,36 @@
export const ORGANIZATION_QUEUE_NAMES = [
"organization.command.createTask",
"organization.command.snapshot.broadcast",
"organization.command.syncGithubSession",
"organization.command.better_auth.session_index.upsert",
"organization.command.better_auth.session_index.delete",
"organization.command.better_auth.email_index.upsert",
"organization.command.better_auth.email_index.delete",
"organization.command.better_auth.account_index.upsert",
"organization.command.better_auth.account_index.delete",
"organization.command.better_auth.verification.create",
"organization.command.better_auth.verification.update",
"organization.command.better_auth.verification.update_many",
"organization.command.better_auth.verification.delete",
"organization.command.better_auth.verification.delete_many",
"organization.command.github.repository_projection.apply",
"organization.command.github.data_projection.apply",
"organization.command.github.sync_progress.apply",
"organization.command.github.webhook_receipt.record",
"organization.command.github.organization_shell.sync_from_github",
"organization.command.shell.profile.update",
"organization.command.shell.sync_started.mark",
"organization.command.billing.stripe_customer.apply",
"organization.command.billing.stripe_subscription.apply",
"organization.command.billing.free_plan.apply",
"organization.command.billing.payment_method.set",
"organization.command.billing.status.set",
"organization.command.billing.invoice.upsert",
"organization.command.billing.seat_usage.record",
] as const;
export type OrganizationQueueName = (typeof ORGANIZATION_QUEUE_NAMES)[number];
export function organizationWorkflowQueueName(name: OrganizationQueueName): OrganizationQueueName {
return name;
}

View file

@ -0,0 +1,349 @@
// @ts-nocheck
import { Loop } from "rivetkit/workflow";
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import type { CreateTaskInput } from "@sandbox-agent/foundry-shared";
import {
applyGithubDataProjectionMutation,
applyGithubRepositoryProjectionMutation,
applyGithubSyncProgressMutation,
createTaskMutation,
recordGithubWebhookReceiptMutation,
refreshOrganizationSnapshotMutation,
} from "./actions.js";
import {
betterAuthCreateVerificationMutation,
betterAuthDeleteAccountIndexMutation,
betterAuthDeleteEmailIndexMutation,
betterAuthDeleteManyVerificationMutation,
betterAuthDeleteSessionIndexMutation,
betterAuthDeleteVerificationMutation,
betterAuthUpdateManyVerificationMutation,
betterAuthUpdateVerificationMutation,
betterAuthUpsertAccountIndexMutation,
betterAuthUpsertEmailIndexMutation,
betterAuthUpsertSessionIndexMutation,
} from "./actions/better-auth.js";
import {
applyOrganizationFreePlanMutation,
applyOrganizationStripeCustomerMutation,
applyOrganizationStripeSubscriptionMutation,
markOrganizationSyncStartedMutation,
recordOrganizationSeatUsageMutation,
setOrganizationBillingPaymentMethodMutation,
setOrganizationBillingStatusMutation,
syncOrganizationShellFromGithubMutation,
updateOrganizationShellProfileMutation,
upsertOrganizationInvoiceMutation,
} from "./app-shell.js";
import { ORGANIZATION_QUEUE_NAMES } from "./queues.js";
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) {
return Loop.continue(undefined);
}
try {
if (msg.name === "organization.command.createTask") {
const result = await loopCtx.step({
name: "organization-create-task",
timeout: 5 * 60_000,
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.snapshot.broadcast") {
await loopCtx.step({
name: "organization-snapshot-broadcast",
timeout: 60_000,
run: async () => refreshOrganizationSnapshotMutation(loopCtx),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.syncGithubSession") {
await loopCtx.step({
name: "organization-sync-github-session",
timeout: 60_000,
run: async () => {
const { syncGithubOrganizations } = await import("./app-shell.js");
await syncGithubOrganizations(loopCtx, msg.body as { sessionId: string; accessToken: string });
},
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.session_index.upsert") {
const result = await loopCtx.step({
name: "organization-better-auth-session-index-upsert",
timeout: 60_000,
run: async () => betterAuthUpsertSessionIndexMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.session_index.delete") {
await loopCtx.step({
name: "organization-better-auth-session-index-delete",
timeout: 60_000,
run: async () => betterAuthDeleteSessionIndexMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.email_index.upsert") {
const result = await loopCtx.step({
name: "organization-better-auth-email-index-upsert",
timeout: 60_000,
run: async () => betterAuthUpsertEmailIndexMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.email_index.delete") {
await loopCtx.step({
name: "organization-better-auth-email-index-delete",
timeout: 60_000,
run: async () => betterAuthDeleteEmailIndexMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.account_index.upsert") {
const result = await loopCtx.step({
name: "organization-better-auth-account-index-upsert",
timeout: 60_000,
run: async () => betterAuthUpsertAccountIndexMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.account_index.delete") {
await loopCtx.step({
name: "organization-better-auth-account-index-delete",
timeout: 60_000,
run: async () => betterAuthDeleteAccountIndexMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.verification.create") {
const result = await loopCtx.step({
name: "organization-better-auth-verification-create",
timeout: 60_000,
run: async () => betterAuthCreateVerificationMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.verification.update") {
const result = await loopCtx.step({
name: "organization-better-auth-verification-update",
timeout: 60_000,
run: async () => betterAuthUpdateVerificationMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.verification.update_many") {
const result = await loopCtx.step({
name: "organization-better-auth-verification-update-many",
timeout: 60_000,
run: async () => betterAuthUpdateManyVerificationMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.verification.delete") {
await loopCtx.step({
name: "organization-better-auth-verification-delete",
timeout: 60_000,
run: async () => betterAuthDeleteVerificationMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.verification.delete_many") {
const result = await loopCtx.step({
name: "organization-better-auth-verification-delete-many",
timeout: 60_000,
run: async () => betterAuthDeleteManyVerificationMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.github.repository_projection.apply") {
await loopCtx.step({
name: "organization-github-repository-projection-apply",
timeout: 60_000,
run: async () => applyGithubRepositoryProjectionMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.github.data_projection.apply") {
await loopCtx.step({
name: "organization-github-data-projection-apply",
timeout: 60_000,
run: async () => applyGithubDataProjectionMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.github.sync_progress.apply") {
await loopCtx.step({
name: "organization-github-sync-progress-apply",
timeout: 60_000,
run: async () => applyGithubSyncProgressMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.github.webhook_receipt.record") {
await loopCtx.step({
name: "organization-github-webhook-receipt-record",
timeout: 60_000,
run: async () => recordGithubWebhookReceiptMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.github.organization_shell.sync_from_github") {
const result = await loopCtx.step({
name: "organization-github-organization-shell-sync-from-github",
timeout: 60_000,
run: async () => syncOrganizationShellFromGithubMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.shell.profile.update") {
await loopCtx.step({
name: "organization-shell-profile-update",
timeout: 60_000,
run: async () => updateOrganizationShellProfileMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.shell.sync_started.mark") {
await loopCtx.step({
name: "organization-shell-sync-started-mark",
timeout: 60_000,
run: async () => markOrganizationSyncStartedMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.stripe_customer.apply") {
await loopCtx.step({
name: "organization-billing-stripe-customer-apply",
timeout: 60_000,
run: async () => applyOrganizationStripeCustomerMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.stripe_subscription.apply") {
await loopCtx.step({
name: "organization-billing-stripe-subscription-apply",
timeout: 60_000,
run: async () => applyOrganizationStripeSubscriptionMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.free_plan.apply") {
await loopCtx.step({
name: "organization-billing-free-plan-apply",
timeout: 60_000,
run: async () => applyOrganizationFreePlanMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.payment_method.set") {
await loopCtx.step({
name: "organization-billing-payment-method-set",
timeout: 60_000,
run: async () => setOrganizationBillingPaymentMethodMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.status.set") {
await loopCtx.step({
name: "organization-billing-status-set",
timeout: 60_000,
run: async () => setOrganizationBillingStatusMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.invoice.upsert") {
await loopCtx.step({
name: "organization-billing-invoice-upsert",
timeout: 60_000,
run: async () => upsertOrganizationInvoiceMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.seat_usage.record") {
await loopCtx.step({
name: "organization-billing-seat-usage-record",
timeout: 60_000,
run: async () => recordOrganizationSeatUsageMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
} catch (error) {
const message = resolveErrorMessage(error);
logActorWarning("organization", "organization workflow command failed", {
queueName: msg.name,
error: message,
});
await msg.complete({ error: message }).catch((completeError: unknown) => {
logActorWarning("organization", "organization workflow failed completing error response", {
queueName: msg.name,
error: resolveErrorMessage(completeError),
});
});
}
return Loop.continue(undefined);
});
}

View file

@ -1,9 +1,7 @@
// @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,
@ -12,19 +10,21 @@ import type {
WorkspaceSessionSummary,
WorkspaceTaskSummary,
} from "@sandbox-agent/foundry-shared";
import { getGithubData, getOrCreateAuditLog, getOrCreateOrganization, getOrCreateTask, getTask, selfRepository } from "../handles.js";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateAuditLog, getOrCreateOrganization, getOrCreateTask, getTask } from "../handles.js";
import { organizationWorkflowQueueName } from "../organization/queues.js";
import { taskWorkflowQueueName } from "../task/workflow/index.js";
import { deriveFallbackTitle, resolveCreateFlowDecision } from "../../services/create-flow.js";
import { expectQueueResponse } from "../../services/queue.js";
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
import { defaultSandboxProviderId } from "../../sandbox-config.js";
import { repoMeta, taskIndex, tasks } 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;
}
@ -38,18 +38,8 @@ interface ListTaskSummariesCommand {
includeArchived?: boolean;
}
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;
interface GetProjectedTaskSummaryCommand {
taskId: string;
}
function isStaleTaskReferenceError(error: unknown): boolean {
@ -109,26 +99,14 @@ async function upsertTaskSummary(c: any, taskSummary: WorkspaceTaskSummary): Pro
async function notifyOrganizationSnapshotChanged(c: any): Promise<void> {
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.refreshOrganizationSnapshot({});
await expectQueueResponse<{ ok: true }>(
await organization.send(organizationWorkflowQueueName("organization.command.snapshot.broadcast"), {}, { wait: true, timeout: 10_000 }),
);
}
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 readStoredRemoteUrl(c: any): Promise<string | null> {
const row = await c.db.select({ remoteUrl: repoMeta.remoteUrl }).from(repoMeta).where(eq(repoMeta.id, 1)).get();
return row?.remoteUrl ?? null;
}
async function deleteStaleTaskIndexRow(c: any, taskId: string): Promise<void> {
@ -164,31 +142,6 @@ async function listKnownTaskBranches(c: any): Promise<string[]> {
return rows.map((row) => row.branchName).filter((value): value is string => typeof value === "string" && value.trim().length > 0);
}
function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
if (!value) {
return fallback;
}
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
function taskSummaryRowFromSummary(taskSummary: WorkspaceTaskSummary) {
return {
taskId: taskSummary.id,
title: taskSummary.title,
status: taskSummary.status,
repoName: taskSummary.repoName,
updatedAtMs: taskSummary.updatedAtMs,
branch: taskSummary.branch,
pullRequestJson: JSON.stringify(taskSummary.pullRequest),
sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary),
};
}
async function resolveGitHubRepository(c: any) {
const githubData = getGithubData(c, c.state.organizationId);
return await githubData.getRepository({ repoId: c.state.repoId }).catch(() => null);
@ -199,17 +152,29 @@ async function listGitHubBranches(c: any): Promise<Array<{ branchName: string; c
return await githubData.listBranchesForRepository({ repoId: c.state.repoId }).catch(() => []);
}
async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
async function resolveRepositoryRemoteUrl(c: any): Promise<string> {
const storedRemoteUrl = await readStoredRemoteUrl(c);
if (storedRemoteUrl) {
return storedRemoteUrl;
}
const repository = await resolveGitHubRepository(c);
const remoteUrl = repository?.cloneUrl?.trim();
if (!remoteUrl) {
throw new Error(`Missing remote URL for repo ${c.state.repoId}`);
}
return remoteUrl;
}
export async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
const organizationId = c.state.organizationId;
const repoId = c.state.repoId;
const repoRemote = c.state.remoteUrl;
await resolveRepositoryRemoteUrl(c);
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);
@ -251,15 +216,6 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
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) {
@ -268,7 +224,21 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
throw error;
}
const created = await taskHandle.initialize({ sandboxProviderId: cmd.sandboxProviderId });
const created = await expectQueueResponse<TaskRecord>(
await taskHandle.send(
taskWorkflowQueueName("task.command.initialize"),
{
sandboxProviderId: cmd.sandboxProviderId,
branchName: initialBranchName,
title: initialTitle,
task: cmd.task,
},
{
wait: true,
timeout: 10_000,
},
),
);
try {
await upsertTaskSummary(c, await taskHandle.getTaskSummary({}));
@ -313,25 +283,12 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
return created;
}
async function upsertTaskSummary(c: any, taskSummary: WorkspaceTaskSummary): Promise<void> {
await c.db
.insert(tasks)
.values(taskSummaryRowFromSummary(taskSummary))
.onConflictDoUpdate({
target: tasks.taskId,
set: taskSummaryRowFromSummary(taskSummary),
})
.run();
}
async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> {
export 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)
@ -397,6 +354,7 @@ async function listTaskSummaries(c: any, includeArchived = false): Promise<TaskS
title: row.title,
status: row.status,
updatedAt: row.updatedAtMs,
pullRequest: parseJsonValue<WorkspacePullRequestSummary | null>(row.pullRequestJson, null),
}))
.filter((row) => includeArchived || row.status !== "archived");
}
@ -413,12 +371,8 @@ function sortOverviewBranches(
taskId: string | null;
taskTitle: string | null;
taskStatus: TaskRecord["status"] | null;
prNumber: number | null;
prState: string | null;
prUrl: string | null;
pullRequest: WorkspacePullRequestSummary | null;
ciStatus: string | null;
reviewStatus: string | null;
reviewer: string | null;
updatedAt: number;
}>,
defaultBranch: string | null,
@ -438,60 +392,59 @@ function sortOverviewBranches(
});
}
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,
export async function applyTaskSummaryUpdateMutation(c: any, input: { taskSummary: WorkspaceTaskSummary }): Promise<void> {
await upsertTaskSummary(c, input.taskSummary);
await notifyOrganizationSnapshotChanged(c);
}
export async function removeTaskSummaryMutation(c: any, input: { taskId: string }): Promise<void> {
await c.db.delete(tasks).where(eq(tasks.taskId, input.taskId)).run();
await notifyOrganizationSnapshotChanged(c);
}
export async function refreshTaskSummaryForBranchMutation(
c: any,
input: { branchName: string; pullRequest?: WorkspacePullRequestSummary | null },
): Promise<void> {
const pullRequest = input.pullRequest ?? null;
let rows = await c.db.select({ taskId: tasks.taskId }).from(tasks).where(eq(tasks.branch, input.branchName)).all();
if (rows.length === 0 && pullRequest) {
const { config } = getActorRuntimeContext();
const created = await createTaskMutation(c, {
task: pullRequest.title?.trim() || `Review ${input.branchName}`,
sandboxProviderId: defaultSandboxProviderId(config),
explicitTitle: pullRequest.title?.trim() || input.branchName,
explicitBranchName: null,
onBranch: input.branchName,
});
if (!msg) {
return Loop.continue(undefined);
}
rows = [{ taskId: created.taskId }];
}
for (const row of rows) {
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);
}
const task = getTask(c, c.state.organizationId, c.state.repoId, row.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(
taskWorkflowQueueName("task.command.pull_request.sync"),
{ pullRequest },
{ wait: true, timeout: 10_000 },
),
);
} catch (error) {
const message = resolveErrorMessage(error);
logActorWarning("repository", "repository workflow command failed", {
queueName: msg.name,
error: message,
logActorWarning("repository", "failed refreshing task summary for branch", {
organizationId: c.state.organizationId,
repoId: c.state.repoId,
branchName: input.branchName,
taskId: row.taskId,
error: resolveErrorMessage(error),
});
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);
},
@ -506,23 +459,19 @@ export const repositoryActions = {
async getRepositoryMetadata(c: any): Promise<{ defaultBranch: string | null; fullName: string | null; remoteUrl: string }> {
const repository = await resolveGitHubRepository(c);
const remoteUrl = await resolveRepositoryRemoteUrl(c);
return {
defaultBranch: repository?.defaultBranch ?? null,
fullName: repository?.fullName ?? null,
remoteUrl: c.state.remoteUrl,
remoteUrl,
};
},
async getRepoOverview(c: any): Promise<RepoOverview> {
await persistRemoteUrl(c, c.state.remoteUrl);
const now = Date.now();
const repository = await resolveGitHubRepository(c);
const remoteUrl = await resolveRepositoryRemoteUrl(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().from(tasks).all();
const taskMetaByBranch = new Map<
@ -558,19 +507,15 @@ export const repositoryActions = {
const branches = sortOverviewBranches(
[...branchMap.values()].map((branch) => {
const taskMeta = taskMetaByBranch.get(branch.branchName);
const pr = taskMeta?.pullRequest ?? prByBranch.get(branch.branchName) ?? null;
const pr = taskMeta?.pullRequest ?? null;
return {
branchName: branch.branchName,
commitSha: branch.commitSha,
taskId: taskMeta?.taskId ?? null,
taskTitle: taskMeta?.title ?? null,
taskStatus: taskMeta?.status ?? null,
prNumber: pr?.number ?? null,
prState: "state" in (pr ?? {}) ? pr.state : null,
prUrl: "url" in (pr ?? {}) ? pr.url : null,
pullRequest: pr,
ciStatus: null,
reviewStatus: pr && "isDraft" in pr ? (pr.isDraft ? "draft" : "ready") : null,
reviewer: pr?.authorLogin ?? null,
updatedAt: Math.max(taskMeta?.updatedAt ?? 0, pr?.updatedAtMs ?? 0, now),
};
}),
@ -580,58 +525,24 @@ export const repositoryActions = {
return {
organizationId: c.state.organizationId,
repoId: c.state.repoId,
remoteUrl: c.state.remoteUrl,
remoteUrl,
baseRef: repository?.defaultBranch ?? null,
fetchedAt: now,
branches,
};
},
async applyTaskSummaryUpdate(c: any, input: { taskSummary: WorkspaceTaskSummary }): Promise<void> {
await upsertTaskSummary(c, input.taskSummary);
await notifyOrganizationSnapshotChanged(c);
},
async removeTaskSummary(c: any, input: { taskId: string }): Promise<void> {
await c.db.delete(tasks).where(eq(tasks.taskId, input.taskId)).run();
await notifyOrganizationSnapshotChanged(c);
},
async findTaskForBranch(c: any, input: { branchName: string }): Promise<{ taskId: string | null }> {
const row = await c.db.select({ taskId: tasks.taskId }).from(tasks).where(eq(tasks.branch, input.branchName)).get();
return { taskId: row?.taskId ?? null };
},
async refreshTaskSummaryForBranch(c: any, input: { branchName: string }): Promise<void> {
const rows = await c.db.select({ taskId: tasks.taskId }).from(tasks).where(eq(tasks.branch, input.branchName)).all();
for (const row of rows) {
try {
const task = getTask(c, c.state.organizationId, c.state.repoId, row.taskId);
await upsertTaskSummary(c, await task.getTaskSummary({}));
} catch (error) {
logActorWarning("repository", "failed refreshing task summary for branch", {
organizationId: c.state.organizationId,
repoId: c.state.repoId,
branchName: input.branchName,
taskId: row.taskId,
error: resolveErrorMessage(error),
});
}
}
await notifyOrganizationSnapshotChanged(c);
},
async getPullRequestForBranch(c: any, cmd: GetPullRequestForBranchCommand): Promise<WorkspacePullRequestSummary | null> {
const branchName = cmd.branchName?.trim();
if (!branchName) {
async getProjectedTaskSummary(c: any, input: GetProjectedTaskSummaryCommand): Promise<WorkspaceTaskSummary | null> {
const taskId = input.taskId?.trim();
if (!taskId) {
return null;
}
const githubData = getGithubData(c, c.state.organizationId);
const rows = await githubData.listPullRequestsForRepository({
repoId: c.state.repoId,
});
return rows.find((candidate: WorkspacePullRequestSummary) => candidate.headRefName === branchName) ?? null;
const row = await c.db.select().from(tasks).where(eq(tasks.taskId, taskId)).get();
return row ? taskSummaryFromRow(c, row) : null;
},
};

View file

@ -1,12 +1,12 @@
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";
import { repositoryActions } from "./actions.js";
import { REPOSITORY_QUEUE_NAMES, runRepositoryWorkflow } from "./workflow.js";
export interface RepositoryInput {
organizationId: string;
repoId: string;
remoteUrl: string;
}
export const repository = actor({
@ -20,7 +20,6 @@ export const repository = actor({
createState: (_c, input: RepositoryInput) => ({
organizationId: input.organizationId,
repoId: input.repoId,
remoteUrl: input.remoteUrl,
}),
actions: repositoryActions,
run: workflow(runRepositoryWorkflow),

View file

@ -0,0 +1,97 @@
// @ts-nocheck
import { Loop } from "rivetkit/workflow";
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import {
applyTaskSummaryUpdateMutation,
createTaskMutation,
refreshTaskSummaryForBranchMutation,
registerTaskBranchMutation,
removeTaskSummaryMutation,
} from "./actions.js";
export const REPOSITORY_QUEUE_NAMES = [
"repository.command.createTask",
"repository.command.registerTaskBranch",
"repository.command.applyTaskSummaryUpdate",
"repository.command.removeTaskSummary",
"repository.command.refreshTaskSummaryForBranch",
] as const;
export type RepositoryQueueName = (typeof REPOSITORY_QUEUE_NAMES)[number];
export function repositoryWorkflowQueueName(name: RepositoryQueueName): RepositoryQueueName {
return name;
}
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),
});
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),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "repository.command.applyTaskSummaryUpdate") {
await loopCtx.step({
name: "repository-apply-task-summary-update",
timeout: 30_000,
run: async () => applyTaskSummaryUpdateMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "repository.command.removeTaskSummary") {
await loopCtx.step({
name: "repository-remove-task-summary",
timeout: 30_000,
run: async () => removeTaskSummaryMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "repository.command.refreshTaskSummaryForBranch") {
await loopCtx.step({
name: "repository-refresh-task-summary-for-branch",
timeout: 60_000,
run: async () => refreshTaskSummaryForBranchMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
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);
});
}

View file

@ -2,6 +2,7 @@ import { actor } from "rivetkit";
import { e2b, sandboxActor } from "rivetkit/sandbox";
import { existsSync } from "node:fs";
import Dockerode from "dockerode";
import { DEFAULT_WORKSPACE_MODEL_GROUPS, workspaceModelGroupsFromSandboxAgents, type WorkspaceModelGroup } from "@sandbox-agent/foundry-shared";
import { SandboxAgent } from "sandbox-agent";
import { getActorRuntimeContext } from "../context.js";
import { organizationKey } from "../keys.js";
@ -258,6 +259,26 @@ async function providerForConnection(c: any): Promise<any | null> {
return provider;
}
async function listWorkspaceModelGroupsForSandbox(c: any): Promise<WorkspaceModelGroup[]> {
const provider = await providerForConnection(c);
if (!provider || !c.state.sandboxId || typeof provider.connectAgent !== "function") {
return DEFAULT_WORKSPACE_MODEL_GROUPS;
}
try {
const client = await provider.connectAgent(c.state.sandboxId, {
waitForHealth: {
timeoutMs: 15_000,
},
});
const listed = await client.listAgents({ config: true });
const groups = workspaceModelGroupsFromSandboxAgents(Array.isArray(listed?.agents) ? listed.agents : []);
return groups.length > 0 ? groups : DEFAULT_WORKSPACE_MODEL_GROUPS;
} catch {
return DEFAULT_WORKSPACE_MODEL_GROUPS;
}
}
const baseActions = baseTaskSandbox.config.actions as Record<string, (c: any, ...args: any[]) => Promise<any>>;
export const taskSandbox = actor({
@ -360,6 +381,10 @@ export const taskSandbox = actor({
}
},
async listWorkspaceModelGroups(c: any): Promise<WorkspaceModelGroup[]> {
return await listWorkspaceModelGroupsForSandbox(c);
},
async providerState(c: any): Promise<{ sandboxProviderId: "e2b" | "local"; sandboxId: string; state: string; at: number }> {
const { config } = getActorRuntimeContext();
const { taskId } = parseTaskSandboxKey(c.key);

View file

@ -5,8 +5,7 @@ CREATE TABLE `task` (
`task` text NOT NULL,
`sandbox_provider_id` text NOT NULL,
`status` text NOT NULL,
`agent_type` text DEFAULT 'claude',
`pr_submitted` integer DEFAULT 0,
`pull_request_json` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
CONSTRAINT "task_singleton_id_check" CHECK("task"."id" = 1)
@ -15,14 +14,10 @@ CREATE TABLE `task` (
CREATE TABLE `task_runtime` (
`id` integer PRIMARY KEY NOT NULL,
`active_sandbox_id` text,
`active_session_id` text,
`active_switch_target` text,
`active_cwd` text,
`status_message` text,
`git_state_json` text,
`git_state_updated_at` integer,
`provision_stage` text,
`provision_stage_updated_at` integer,
`updated_at` integer NOT NULL,
CONSTRAINT "task_runtime_singleton_id_check" CHECK("task_runtime"."id" = 1)
);
@ -33,7 +28,6 @@ CREATE TABLE `task_sandboxes` (
`sandbox_actor_id` text,
`switch_target` text NOT NULL,
`cwd` text,
`status_message` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
@ -47,10 +41,6 @@ CREATE TABLE `task_workspace_sessions` (
`error_message` text,
`transcript_json` text DEFAULT '[]' NOT NULL,
`transcript_updated_at` integer,
`unread` integer DEFAULT 0 NOT NULL,
`draft_text` text DEFAULT '' NOT NULL,
`draft_attachments_json` text DEFAULT '[]' NOT NULL,
`draft_updated_at` integer,
`created` integer DEFAULT 1 NOT NULL,
`closed` integer DEFAULT 0 NOT NULL,
`thinking_since_ms` integer,

View file

@ -35,8 +35,8 @@
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"sandbox_provider_id": {
"name": "sandbox_provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
@ -49,21 +49,12 @@
"notNull": true,
"autoincrement": false
},
"agent_type": {
"name": "agent_type",
"pull_request_json": {
"name": "pull_request_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'claude'"
},
"pr_submitted": {
"name": "pr_submitted",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
"autoincrement": false
},
"created_at": {
"name": "created_at",
@ -108,13 +99,6 @@
"notNull": false,
"autoincrement": false
},
"active_session_id": {
"name": "active_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active_switch_target": {
"name": "active_switch_target",
"type": "text",
@ -129,13 +113,20 @@
"notNull": false,
"autoincrement": false
},
"status_message": {
"name": "status_message",
"git_state_json": {
"name": "git_state_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"git_state_updated_at": {
"name": "git_state_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
@ -165,8 +156,8 @@
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"sandbox_provider_id": {
"name": "sandbox_provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
@ -193,13 +184,6 @@
"notNull": false,
"autoincrement": false
},
"status_message": {
"name": "status_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
@ -231,6 +215,13 @@
"notNull": true,
"autoincrement": false
},
"sandbox_session_id": {
"name": "sandbox_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_name": {
"name": "session_name",
"type": "text",
@ -245,32 +236,31 @@
"notNull": true,
"autoincrement": false
},
"unread": {
"name": "unread",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"draft_text": {
"name": "draft_text",
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
"default": "'ready'"
},
"draft_attachments_json": {
"name": "draft_attachments_json",
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"transcript_json": {
"name": "transcript_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"draft_updated_at": {
"name": "draft_updated_at",
"transcript_updated_at": {
"name": "transcript_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,

View file

@ -11,6 +11,7 @@ export const task = sqliteTable(
task: text("task").notNull(),
sandboxProviderId: text("sandbox_provider_id").notNull(),
status: text("status").notNull(),
pullRequestJson: text("pull_request_json"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
},
@ -42,7 +43,6 @@ export const taskSandboxes = sqliteTable("task_sandboxes", {
sandboxActorId: text("sandbox_actor_id"),
switchTarget: text("switch_target").notNull(),
cwd: text("cwd"),
statusMessage: text("status_message"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});

View file

@ -1,122 +1,15 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type {
TaskRecord,
TaskWorkspaceChangeModelInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceUpdateDraftInput,
SandboxProviderId,
} from "@sandbox-agent/foundry-shared";
import { expectQueueResponse } from "../../services/queue.js";
import { selfTask } from "../handles.js";
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
import { taskDb } from "./db/db.js";
import { getCurrentRecord } from "./workflow/common.js";
import {
changeWorkspaceModel,
closeWorkspaceSession,
createWorkspaceSession,
getSessionDetail,
getTaskDetail,
getTaskSummary,
markWorkspaceUnread,
publishWorkspacePr,
renameWorkspaceTask,
renameWorkspaceSession,
revertWorkspaceFile,
sendWorkspaceMessage,
syncWorkspaceSessionStatus,
setWorkspaceSessionUnread,
stopWorkspaceSession,
updateWorkspaceDraft,
} from "./workspace.js";
import { TASK_QUEUE_NAMES, taskWorkflowQueueName, runTaskWorkflow } from "./workflow/index.js";
import { getSessionDetail, getTaskDetail, getTaskSummary } from "./workspace.js";
import { TASK_QUEUE_NAMES, runTaskWorkflow } from "./workflow/index.js";
export interface TaskInput {
organizationId: string;
repoId: string;
taskId: string;
repoRemote: string;
branchName: string | null;
title: string | null;
task: string;
sandboxProviderId: SandboxProviderId;
explicitTitle: string | null;
explicitBranchName: string | null;
}
interface InitializeCommand {
sandboxProviderId?: SandboxProviderId;
}
interface TaskActionCommand {
reason?: string;
}
interface TaskSessionCommand {
sessionId: string;
authSessionId?: string;
}
interface TaskStatusSyncCommand {
sessionId: string;
status: "running" | "idle" | "error";
at: number;
}
interface TaskWorkspaceValueCommand {
value: string;
authSessionId?: string;
}
interface TaskWorkspaceSessionTitleCommand {
sessionId: string;
title: string;
authSessionId?: string;
}
interface TaskWorkspaceSessionUnreadCommand {
sessionId: string;
unread: boolean;
authSessionId?: string;
}
interface TaskWorkspaceUpdateDraftCommand {
sessionId: string;
text: string;
attachments: Array<any>;
authSessionId?: string;
}
interface TaskWorkspaceChangeModelCommand {
sessionId: string;
model: string;
authSessionId?: string;
}
interface TaskWorkspaceSendMessageCommand {
sessionId: string;
text: string;
attachments: Array<any>;
authSessionId?: string;
}
interface TaskWorkspaceCreateSessionCommand {
model?: string;
authSessionId?: string;
}
interface TaskWorkspaceCreateSessionAndSendCommand {
model?: string;
text: string;
authSessionId?: string;
}
interface TaskWorkspaceSessionCommand {
sessionId: string;
authSessionId?: string;
}
export const task = actor({
@ -131,85 +24,10 @@ export const task = actor({
organizationId: input.organizationId,
repoId: input.repoId,
taskId: input.taskId,
repoRemote: input.repoRemote,
}),
actions: {
async initialize(c, cmd: InitializeCommand): Promise<TaskRecord> {
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.initialize"), cmd ?? {}, {
wait: true,
timeout: 10_000,
});
return expectQueueResponse<TaskRecord>(result);
},
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
wait: false,
});
return { ok: true };
},
async attach(c, cmd?: TaskActionCommand): Promise<{ target: string; sessionId: string | null }> {
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.attach"), cmd ?? {}, {
wait: true,
timeout: 10_000,
});
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
},
async switch(c): Promise<{ switchTarget: string }> {
const self = selfTask(c);
const result = await self.send(
taskWorkflowQueueName("task.command.switch"),
{},
{
wait: true,
timeout: 10_000,
},
);
return expectQueueResponse<{ switchTarget: string }>(result);
},
async push(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, {
wait: false,
});
},
async sync(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, {
wait: false,
});
},
async merge(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, {
wait: false,
});
},
async archive(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, {
wait: false,
});
},
async kill(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, {
wait: false,
});
},
async get(c): Promise<TaskRecord> {
return await getCurrentRecord({ db: c.db, state: c.state });
return await getCurrentRecord(c);
},
async getTaskSummary(c) {
@ -223,175 +41,6 @@ export const task = actor({
async getSessionDetail(c, input: { sessionId: string; authSessionId?: string }) {
return await getSessionDetail(c, input.sessionId, input.authSessionId);
},
async markWorkspaceUnread(c, input?: { authSessionId?: string }): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.mark_unread"),
{ authSessionId: input?.authSessionId },
{
wait: true,
timeout: 10_000,
},
);
},
async renameWorkspaceTask(c, input: TaskWorkspaceRenameInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.rename_task"),
{ value: input.value, authSessionId: input.authSessionId } satisfies TaskWorkspaceValueCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async createWorkspaceSession(c, input?: { model?: string; authSessionId?: string }): Promise<{ sessionId: string }> {
const self = selfTask(c);
const result = await self.send(
taskWorkflowQueueName("task.command.workspace.create_session"),
{
...(input?.model ? { model: input.model } : {}),
...(input?.authSessionId ? { authSessionId: input.authSessionId } : {}),
} satisfies TaskWorkspaceCreateSessionCommand,
{
wait: true,
timeout: 10_000,
},
);
return expectQueueResponse<{ sessionId: string }>(result);
},
/**
* Fire-and-forget: creates a session and sends the initial message.
* Used by createWorkspaceTask so the caller doesn't block on session creation.
*/
async createWorkspaceSessionAndSend(c, input: { model?: string; text: string; authSessionId?: string }): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.create_session_and_send"),
{ model: input.model, text: input.text, authSessionId: input.authSessionId } satisfies TaskWorkspaceCreateSessionAndSendCommand,
{ wait: false },
);
},
async renameWorkspaceSession(c, input: TaskWorkspaceRenameSessionInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.rename_session"),
{ sessionId: input.sessionId, title: input.title, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionTitleCommand,
{
wait: true,
timeout: 10_000,
},
);
},
async setWorkspaceSessionUnread(c, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.set_session_unread"),
{ sessionId: input.sessionId, unread: input.unread, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionUnreadCommand,
{
wait: true,
timeout: 10_000,
},
);
},
async updateWorkspaceDraft(c, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.update_draft"),
{
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
authSessionId: input.authSessionId,
} satisfies TaskWorkspaceUpdateDraftCommand,
{
wait: false,
},
);
},
async changeWorkspaceModel(c, input: TaskWorkspaceChangeModelInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.change_model"),
{ sessionId: input.sessionId, model: input.model, authSessionId: input.authSessionId } satisfies TaskWorkspaceChangeModelCommand,
{
wait: true,
timeout: 10_000,
},
);
},
async sendWorkspaceMessage(c, input: TaskWorkspaceSendMessageInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.send_message"),
{
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
authSessionId: input.authSessionId,
} satisfies TaskWorkspaceSendMessageCommand,
{
wait: false,
},
);
},
async stopWorkspaceSession(c, input: TaskSessionCommand): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.stop_session"),
{ sessionId: input.sessionId, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionCommand,
{
wait: false,
},
);
},
async syncWorkspaceSessionStatus(c, input: TaskStatusSyncCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workspace.sync_session_status"), input, {
wait: true,
timeout: 20_000,
});
},
async closeWorkspaceSession(c, input: TaskSessionCommand): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.close_session"),
{ sessionId: input.sessionId, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionCommand,
{
wait: false,
},
);
},
async publishWorkspacePr(c): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.publish_pr"),
{},
{
wait: false,
},
);
},
async revertWorkspaceFile(c, input: { path: string }): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workspace.revert_file"), input, {
wait: false,
});
},
},
run: workflow(runTaskWorkflow),
});

View file

@ -65,7 +65,7 @@ export async function handlePushActivity(loopCtx: any, msg: any): Promise<void>
await msg.complete({ ok: true });
}
export async function handleSimpleCommandActivity(loopCtx: any, msg: any, _statusMessage: string, historyKind: string): Promise<void> {
export async function handleSimpleCommandActivity(loopCtx: any, msg: any, historyKind: string): Promise<void> {
await appendAuditLog(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true });
}

View file

@ -2,7 +2,7 @@
import { eq } from "drizzle-orm";
import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared";
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
import { getOrCreateAuditLog } from "../../handles.js";
import { getOrCreateAuditLog, getOrCreateRepository } from "../../handles.js";
import { broadcastTaskUpdate } from "../workspace.js";
export const TASK_ROW_ID = 1;
@ -66,6 +66,7 @@ export async function setTaskState(ctx: any, status: TaskStatus): Promise<void>
export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
const db = ctx.db;
const repository = await getOrCreateRepository(ctx, ctx.state.organizationId, ctx.state.repoId);
const row = await db
.select({
branchName: taskTable.branchName,
@ -73,6 +74,7 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
task: taskTable.task,
sandboxProviderId: taskTable.sandboxProviderId,
status: taskTable.status,
pullRequestJson: taskTable.pullRequestJson,
activeSandboxId: taskRuntime.activeSandboxId,
createdAt: taskTable.createdAt,
updatedAt: taskTable.updatedAt,
@ -86,6 +88,16 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
throw new Error(`Task not found: ${ctx.state.taskId}`);
}
const repositoryMetadata = await repository.getRepositoryMetadata({});
let pullRequest = null;
if (row.pullRequestJson) {
try {
pullRequest = JSON.parse(row.pullRequestJson);
} catch {
pullRequest = null;
}
}
const sandboxes = await db
.select({
sandboxId: taskSandboxes.sandboxId,
@ -102,7 +114,7 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
return {
organizationId: ctx.state.organizationId,
repoId: ctx.state.repoId,
repoRemote: ctx.state.repoRemote,
repoRemote: repositoryMetadata.remoteUrl,
taskId: ctx.state.taskId,
branchName: row.branchName,
title: row.title,
@ -110,6 +122,7 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
sandboxProviderId: row.sandboxProviderId,
status: row.status,
activeSandboxId: row.activeSandboxId ?? null,
pullRequest,
sandboxes: sandboxes.map((sb) => ({
sandboxId: sb.sandboxId,
sandboxProviderId: sb.sandboxProviderId,
@ -119,25 +132,20 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
createdAt: sb.createdAt,
updatedAt: sb.updatedAt,
})),
diffStat: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
} as TaskRecord;
}
export async function appendAuditLog(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
const row = await ctx.db.select({ branchName: taskTable.branchName }).from(taskTable).where(eq(taskTable.id, TASK_ROW_ID)).get();
const auditLog = await getOrCreateAuditLog(ctx, ctx.state.organizationId, ctx.state.repoId);
await auditLog.send(
"auditLog.command.append",
{
kind,
taskId: ctx.state.taskId,
branchName: ctx.state.branchName,
branchName: row?.branchName ?? null,
payload,
},
{

View file

@ -24,10 +24,12 @@ import {
publishWorkspacePr,
renameWorkspaceTask,
renameWorkspaceSession,
selectWorkspaceSession,
revertWorkspaceFile,
sendWorkspaceMessage,
setWorkspaceSessionUnread,
stopWorkspaceSession,
syncTaskPullRequest,
syncWorkspaceSessionStatus,
updateWorkspaceDraft,
} from "../workspace.js";
@ -71,7 +73,7 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, msg.body));
await msg.complete({ ok: true });
} catch (error) {
await loopCtx.step("init-failed-v3", async () => initFailedActivity(loopCtx, error));
await loopCtx.step("init-failed-v3", async () => initFailedActivity(loopCtx, error, msg.body));
await msg.complete({
ok: false,
error: resolveErrorMessage(error),
@ -92,11 +94,11 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
},
"task.command.sync": async (loopCtx, msg) => {
await loopCtx.step("handle-sync", async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "task.sync"));
await loopCtx.step("handle-sync", async () => handleSimpleCommandActivity(loopCtx, msg, "task.sync"));
},
"task.command.merge": async (loopCtx, msg) => {
await loopCtx.step("handle-merge", async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "task.merge"));
await loopCtx.step("handle-merge", async () => handleSimpleCommandActivity(loopCtx, msg, "task.merge"));
},
"task.command.archive": async (loopCtx, msg) => {
@ -112,6 +114,11 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
await loopCtx.step("handle-get", async () => handleGetActivity(loopCtx, msg));
},
"task.command.pull_request.sync": async (loopCtx, msg) => {
await loopCtx.step("task-pull-request-sync", async () => syncTaskPullRequest(loopCtx, msg.body?.pullRequest ?? null));
await msg.complete({ ok: true });
},
"task.command.workspace.mark_unread": async (loopCtx, msg) => {
await loopCtx.step("workspace-mark-unread", async () => markWorkspaceUnread(loopCtx, msg.body?.authSessionId));
await msg.complete({ ok: true });
@ -169,22 +176,23 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
await msg.complete({ ok: true });
},
"task.command.workspace.select_session": async (loopCtx, msg) => {
await loopCtx.step("workspace-select-session", async () => selectWorkspaceSession(loopCtx, msg.body.sessionId, msg.body?.authSessionId));
await msg.complete({ ok: true });
},
"task.command.workspace.set_session_unread": async (loopCtx, msg) => {
await loopCtx.step("workspace-set-session-unread", async () =>
setWorkspaceSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread, msg.body?.authSessionId),
);
await loopCtx.step("workspace-set-session-unread", async () => setWorkspaceSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread, msg.body?.authSessionId));
await msg.complete({ ok: true });
},
"task.command.workspace.update_draft": async (loopCtx, msg) => {
await loopCtx.step("workspace-update-draft", async () =>
updateWorkspaceDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments, msg.body?.authSessionId),
);
await loopCtx.step("workspace-update-draft", async () => updateWorkspaceDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments, msg.body?.authSessionId));
await msg.complete({ ok: true });
},
"task.command.workspace.change_model": async (loopCtx, msg) => {
await loopCtx.step("workspace-change-model", async () => changeWorkspaceModel(loopCtx, msg.body.sessionId, msg.body.model));
await loopCtx.step("workspace-change-model", async () => changeWorkspaceModel(loopCtx, msg.body.sessionId, msg.body.model, msg.body?.authSessionId));
await msg.complete({ ok: true });
},

View file

@ -11,28 +11,34 @@ import { taskWorkflowQueueName } from "./queue.js";
export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<void> {
const { config } = getActorRuntimeContext();
const sandboxProviderId = body?.sandboxProviderId ?? defaultSandboxProviderId(config);
const task = body?.task;
if (typeof task !== "string" || task.trim().length === 0) {
throw new Error("task initialize requires the task prompt");
}
const now = Date.now();
await loopCtx.db
.insert(taskTable)
.values({
id: TASK_ROW_ID,
branchName: loopCtx.state.branchName,
title: loopCtx.state.title,
task: loopCtx.state.task,
branchName: body?.branchName ?? null,
title: body?.title ?? null,
task,
sandboxProviderId,
status: "init_bootstrap_db",
pullRequestJson: null,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskTable.id,
set: {
branchName: loopCtx.state.branchName,
title: loopCtx.state.title,
task: loopCtx.state.task,
branchName: body?.branchName ?? null,
title: body?.title ?? null,
task,
sandboxProviderId,
status: "init_bootstrap_db",
pullRequestJson: null,
updatedAt: now,
},
})
@ -99,33 +105,36 @@ export async function initCompleteActivity(loopCtx: any, body: any): Promise<voi
});
}
export async function initFailedActivity(loopCtx: any, error: unknown): Promise<void> {
export async function initFailedActivity(loopCtx: any, error: unknown, body?: any): Promise<void> {
const now = Date.now();
const detail = resolveErrorDetail(error);
const messages = collectErrorMessages(error);
const { config } = getActorRuntimeContext();
const sandboxProviderId = defaultSandboxProviderId(config);
const task = typeof body?.task === "string" ? body.task : null;
await loopCtx.db
.insert(taskTable)
.values({
id: TASK_ROW_ID,
branchName: loopCtx.state.branchName ?? null,
title: loopCtx.state.title ?? null,
task: loopCtx.state.task,
branchName: body?.branchName ?? null,
title: body?.title ?? null,
task: task ?? detail,
sandboxProviderId,
status: "error",
pullRequestJson: null,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskTable.id,
set: {
branchName: loopCtx.state.branchName ?? null,
title: loopCtx.state.title ?? null,
task: loopCtx.state.task,
branchName: body?.branchName ?? null,
title: body?.title ?? null,
task: task ?? detail,
sandboxProviderId,
status: "error",
pullRequestJson: null,
updatedAt: now,
},
})

View file

@ -1,8 +1,6 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { getTaskSandbox } from "../../handles.js";
import { resolveOrganizationGithubAuth } from "../../../services/github-auth.js";
import { taskSandboxes } from "../db/schema.js";
import { appendAuditLog, getCurrentRecord } from "./common.js";
export interface PushActiveBranchOptions {
@ -13,7 +11,7 @@ export interface PushActiveBranchOptions {
export async function pushActiveBranchActivity(loopCtx: any, options: PushActiveBranchOptions = {}): Promise<void> {
const record = await getCurrentRecord(loopCtx);
const activeSandboxId = record.activeSandboxId;
const branchName = loopCtx.state.branchName ?? record.branchName;
const branchName = record.branchName;
if (!activeSandboxId) {
throw new Error("cannot push: no active sandbox");
@ -28,13 +26,6 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive
throw new Error("cannot push: active sandbox cwd is not set");
}
const now = Date.now();
await loopCtx.db
.update(taskSandboxes)
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
.run();
const script = [
"set -euo pipefail",
`cd ${JSON.stringify(cwd)}`,
@ -62,13 +53,6 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive
throw new Error(`git push failed (${result.exitCode ?? 1}): ${[result.stdout, result.stderr].filter(Boolean).join("")}`);
}
const updatedAt = Date.now();
await loopCtx.db
.update(taskSandboxes)
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
.run();
await appendAuditLog(loopCtx, options.historyKind ?? "task.push", {
reason: options.reason ?? null,
branchName,

View file

@ -9,12 +9,14 @@ export const TASK_QUEUE_NAMES = [
"task.command.archive",
"task.command.kill",
"task.command.get",
"task.command.pull_request.sync",
"task.command.workspace.mark_unread",
"task.command.workspace.rename_task",
"task.command.workspace.create_session",
"task.command.workspace.create_session_and_send",
"task.command.workspace.ensure_session",
"task.command.workspace.rename_session",
"task.command.workspace.select_session",
"task.command.workspace.set_session_unread",
"task.command.workspace.update_draft",
"task.command.workspace.change_model",

View file

@ -2,13 +2,17 @@
import { randomUUID } from "node:crypto";
import { basename, dirname } from "node:path";
import { asc, eq } from "drizzle-orm";
import { DEFAULT_WORKSPACE_MODEL_GROUPS, DEFAULT_WORKSPACE_MODEL_ID, workspaceAgentForModel, workspaceSandboxAgentIdForModel } from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateRepository, getOrCreateTaskSandbox, getOrCreateUser, getTaskSandbox, selfTask } from "../handles.js";
import { SANDBOX_REPO_CWD } from "../sandbox/index.js";
import { resolveSandboxProviderId } from "../../sandbox-config.js";
import { getBetterAuthService } from "../../services/better-auth.js";
import { expectQueueResponse } from "../../services/queue.js";
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
import { githubRepoFullNameFromRemote } from "../../services/repo.js";
import { repositoryWorkflowQueueName } from "../repository/workflow.js";
import { userWorkflowQueueName } from "../user/workflow.js";
import { task as taskTable, taskRuntime, taskSandboxes, taskWorkspaceSessions } from "./db/schema.js";
import { getCurrentRecord } from "./workflow/common.js";
@ -21,24 +25,29 @@ function emptyGitState() {
};
}
const FALLBACK_MODEL = "claude-sonnet-4";
function isCodexModel(model: string) {
return model.startsWith("gpt-") || model.startsWith("o");
}
const FALLBACK_MODEL = DEFAULT_WORKSPACE_MODEL_ID;
function agentKindForModel(model: string) {
if (isCodexModel(model)) {
return "Codex";
}
return "Claude";
return workspaceAgentForModel(model);
}
export function agentTypeForModel(model: string) {
if (isCodexModel(model)) {
return "codex";
export function sandboxAgentIdForModel(model: string) {
return workspaceSandboxAgentIdForModel(model);
}
async function resolveWorkspaceModelGroups(c: any): Promise<any[]> {
try {
const sandbox = await getOrCreateTaskSandbox(c, c.state.organizationId, stableSandboxId(c));
const groups = await sandbox.listWorkspaceModelGroups();
return Array.isArray(groups) && groups.length > 0 ? groups : DEFAULT_WORKSPACE_MODEL_GROUPS;
} catch {
return DEFAULT_WORKSPACE_MODEL_GROUPS;
}
return "claude";
}
async function resolveSandboxAgentForModel(c: any, model: string): Promise<string> {
const groups = await resolveWorkspaceModelGroups(c);
return workspaceSandboxAgentIdForModel(model, groups);
}
function repoLabelFromRemote(remoteUrl: string): string {
@ -56,6 +65,11 @@ function repoLabelFromRemote(remoteUrl: string): string {
return basename(trimmed.replace(/\.git$/, ""));
}
async function getRepositoryMetadata(c: any): Promise<{ defaultBranch: string | null; fullName: string | null; remoteUrl: string }> {
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId);
return await repository.getRepositoryMetadata({});
}
function parseDraftAttachments(value: string | null | undefined): Array<any> {
if (!value) {
return [];
@ -220,11 +234,17 @@ async function upsertUserTaskState(c: any, authSessionId: string | null | undefi
}
const user = await getOrCreateUser(c, userId);
await user.upsertTaskState({
taskId: c.state.taskId,
sessionId,
patch,
});
expectQueueResponse(
await user.send(
userWorkflowQueueName("user.command.task_state.upsert"),
{
taskId: c.state.taskId,
sessionId,
patch,
},
{ wait: true, timeout: 60_000 },
),
);
}
async function deleteUserTaskState(c: any, authSessionId: string | null | undefined, sessionId: string): Promise<void> {
@ -239,10 +259,16 @@ async function deleteUserTaskState(c: any, authSessionId: string | null | undefi
}
const user = await getOrCreateUser(c, userId);
await user.deleteTaskState({
taskId: c.state.taskId,
sessionId,
});
expectQueueResponse(
await user.send(
userWorkflowQueueName("user.command.task_state.delete"),
{
taskId: c.state.taskId,
sessionId,
},
{ wait: true, timeout: 60_000 },
),
);
}
async function resolveDefaultModel(c: any, authSessionId?: string | null): Promise<string> {
@ -367,7 +393,7 @@ async function getTaskSandboxRuntime(
}> {
const { config } = getActorRuntimeContext();
const sandboxId = stableSandboxId(c);
const sandboxProviderId = resolveSandboxProviderId(config, record.sandboxProviderId ?? c.state.sandboxProviderId ?? null);
const sandboxProviderId = resolveSandboxProviderId(config, record.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 = sandboxProviderId === "local" ? `sandbox://local/${sandboxId}` : `sandbox://e2b/${sandboxId}`;
@ -381,7 +407,6 @@ async function getTaskSandboxRuntime(
sandboxActorId: typeof actorId === "string" ? actorId : null,
switchTarget,
cwd: SANDBOX_REPO_CWD,
statusMessage: "sandbox ready",
createdAt: now,
updatedAt: now,
})
@ -436,8 +461,7 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { ski
}
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 metadata = await getRepositoryMetadata(c);
const baseRef = metadata.defaultBranch ?? "main";
const sandboxRepoRoot = dirname(SANDBOX_REPO_CWD);
const script = [
@ -445,7 +469,7 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { ski
`mkdir -p ${JSON.stringify(sandboxRepoRoot)}`,
"git config --global credential.helper '!f() { echo username=x-access-token; echo password=${GH_TOKEN:-$GITHUB_TOKEN}; }; f'",
`if [ ! -d ${JSON.stringify(`${SANDBOX_REPO_CWD}/.git`)} ]; then rm -rf ${JSON.stringify(SANDBOX_REPO_CWD)} && git clone ${JSON.stringify(
c.state.repoRemote,
metadata.remoteUrl,
)} ${JSON.stringify(SANDBOX_REPO_CWD)}; fi`,
`cd ${JSON.stringify(SANDBOX_REPO_CWD)}`,
"git fetch origin --prune",
@ -774,21 +798,8 @@ function computeWorkspaceTaskStatus(record: any, sessions: Array<any>) {
return "idle";
}
async function readPullRequestSummary(c: any, branchName: string | null) {
if (!branchName) {
return null;
}
try {
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote);
return await repository.getPullRequestForBranch({ branchName });
} catch {
return null;
}
}
export async function ensureWorkspaceSeeded(c: any): Promise<any> {
return await getCurrentRecord({ db: c.db, state: c.state });
return await getCurrentRecord(c);
}
function buildSessionSummary(meta: any, userState?: any): any {
@ -853,20 +864,24 @@ function buildSessionDetailFromMeta(meta: any, userState?: any): any {
*/
export async function buildTaskSummary(c: any, authSessionId?: string | null): Promise<any> {
const record = await ensureWorkspaceSeeded(c);
const repositoryMetadata = await getRepositoryMetadata(c);
const sessions = await listSessionMetaRows(c);
await maybeScheduleWorkspaceRefreshes(c, record, sessions);
const userTaskState = await getUserTaskState(c, authSessionId);
const taskStatus = computeWorkspaceTaskStatus(record, sessions);
const activeSessionId =
userTaskState.activeSessionId && sessions.some((meta) => meta.sessionId === userTaskState.activeSessionId) ? userTaskState.activeSessionId : null;
return {
id: c.state.taskId,
repoId: c.state.repoId,
title: record.title ?? "New Task",
status: taskStatus ?? "new",
repoName: repoLabelFromRemote(c.state.repoRemote),
status: taskStatus,
repoName: repoLabelFromRemote(repositoryMetadata.remoteUrl),
updatedAtMs: record.updatedAt,
branch: record.branchName,
pullRequest: await readPullRequestSummary(c, record.branchName),
pullRequest: record.pullRequest ?? null,
activeSessionId,
sessionsSummary: sessions.map((meta) => buildSessionSummary(meta, userTaskState.bySessionId.get(meta.sessionId))),
};
}
@ -885,10 +900,6 @@ export async function buildTaskDetail(c: any, authSessionId?: string | null): Pr
return {
...summary,
task: record.task,
runtimeStatus: summary.status,
diffStat: record.diffStat ?? null,
prUrl: record.prUrl ?? null,
reviewStatus: record.reviewStatus ?? null,
fileChanges: gitState.fileChanges,
diffs: gitState.diffs,
fileTree: gitState.fileTree,
@ -959,8 +970,14 @@ export async function getSessionDetail(c: any, sessionId: string, authSessionId?
* - Broadcast full detail/session payloads down to direct task subscribers.
*/
export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }): Promise<void> {
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote);
await repository.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) });
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId);
await expectQueueResponse<{ ok: true }>(
await repository.send(
repositoryWorkflowQueueName("repository.command.applyTaskSummaryUpdate"),
{ taskSummary: await buildTaskSummary(c) },
{ wait: true, timeout: 10_000 },
),
);
c.broadcast("taskUpdated", {
type: "taskUpdated",
detail: await buildTaskDetail(c),
@ -1010,6 +1027,19 @@ export async function renameWorkspaceTask(c: any, value: string): Promise<void>
await broadcastTaskUpdate(c);
}
export async function syncTaskPullRequest(c: any, pullRequest: any): Promise<void> {
const now = pullRequest?.updatedAtMs ?? Date.now();
await c.db
.update(taskTable)
.set({
pullRequestJson: pullRequest ? JSON.stringify(pullRequest) : null,
updatedAt: now,
})
.where(eq(taskTable.id, 1))
.run();
await broadcastTaskUpdate(c);
}
export async function createWorkspaceSession(c: any, model?: string, authSessionId?: string): Promise<{ sessionId: string }> {
const sessionId = `session-${randomUUID()}`;
const record = await ensureWorkspaceSeeded(c);
@ -1055,9 +1085,10 @@ export async function ensureWorkspaceSession(c: any, sessionId: string, model?:
const runtime = await getTaskSandboxRuntime(c, record);
await ensureSandboxRepo(c, runtime.sandbox, record);
const resolvedModel = model ?? meta.model ?? (await resolveDefaultModel(c, authSessionId));
const resolvedAgent = await resolveSandboxAgentForModel(c, resolvedModel);
await runtime.sandbox.createSession({
id: meta.sandboxSessionId ?? sessionId,
agent: agentTypeForModel(resolvedModel),
agent: resolvedAgent,
model: resolvedModel,
sessionInit: {
cwd: runtime.cwd,
@ -1113,6 +1144,17 @@ export async function renameWorkspaceSession(c: any, sessionId: string, title: s
await broadcastTaskUpdate(c, { sessionId });
}
export async function selectWorkspaceSession(c: any, sessionId: string, authSessionId?: string): Promise<void> {
const meta = await readSessionMeta(c, sessionId);
if (!meta || meta.closed) {
return;
}
await upsertUserTaskState(c, authSessionId, sessionId, {
activeSessionId: sessionId,
});
await broadcastTaskUpdate(c, { sessionId });
}
export async function setWorkspaceSessionUnread(c: any, sessionId: string, unread: boolean, authSessionId?: string): Promise<void> {
await upsertUserTaskState(c, authSessionId, sessionId, {
unread,
@ -1129,7 +1171,7 @@ export async function updateWorkspaceDraft(c: any, sessionId: string, text: stri
await broadcastTaskUpdate(c, { sessionId });
}
export async function changeWorkspaceModel(c: any, sessionId: string, model: string): Promise<void> {
export async function changeWorkspaceModel(c: any, sessionId: string, model: string, _authSessionId?: string): Promise<void> {
const meta = await readSessionMeta(c, sessionId);
if (!meta || meta.closed) {
return;
@ -1295,6 +1337,13 @@ export async function closeWorkspaceSession(c: any, sessionId: string, authSessi
closed: 1,
thinkingSinceMs: null,
});
const remainingSessions = sessions.filter((candidate) => candidate.sessionId !== sessionId && candidate.closed !== true);
const userTaskState = await getUserTaskState(c, authSessionId);
if (userTaskState.activeSessionId === sessionId && remainingSessions[0]) {
await upsertUserTaskState(c, authSessionId, remainingSessions[0].sessionId, {
activeSessionId: remainingSessions[0].sessionId,
});
}
await deleteUserTaskState(c, authSessionId, sessionId);
await broadcastTaskUpdate(c);
}
@ -1316,19 +1365,30 @@ export async function publishWorkspacePr(c: any): Promise<void> {
if (!record.branchName) {
throw new Error("cannot publish PR without a branch");
}
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);
const metadata = await getRepositoryMetadata(c);
const repoFullName = metadata.fullName ?? githubRepoFullNameFromRemote(metadata.remoteUrl);
if (!repoFullName) {
throw new Error(`Unable to resolve GitHub repository for ${c.state.repoRemote}`);
throw new Error(`Unable to resolve GitHub repository for ${metadata.remoteUrl}`);
}
const { driver } = getActorRuntimeContext();
const auth = await resolveOrganizationGithubAuth(c, c.state.organizationId);
await driver.github.createPr(repoFullName, record.branchName, record.title ?? c.state.task, undefined, {
const created = await driver.github.createPr(repoFullName, record.branchName, record.title ?? record.task, undefined, {
githubToken: auth?.githubToken ?? null,
baseBranch: metadata.defaultBranch ?? undefined,
});
await broadcastTaskUpdate(c);
await syncTaskPullRequest(c, {
number: created.number,
title: record.title ?? record.task,
body: null,
state: "open",
url: created.url,
headRefName: record.branchName,
baseRefName: metadata.defaultBranch ?? "main",
authorLogin: null,
isDraft: false,
merged: false,
updatedAtMs: Date.now(),
});
}
export async function revertWorkspaceFile(c: any, path: string): Promise<void> {

View file

@ -0,0 +1,47 @@
import { asc, count as sqlCount, desc } from "drizzle-orm";
import { applyJoinToRow, applyJoinToRows, buildWhere, columnFor, tableFor } from "../query-helpers.js";
export const betterAuthActions = {
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async betterAuthFindOneRecord(c, input: { model: string; where: any[]; join?: any }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
const row = predicate ? await c.db.select().from(table).where(predicate).get() : await c.db.select().from(table).get();
return await applyJoinToRow(c, input.model, row ?? null, input.join);
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async betterAuthFindManyRecords(c, input: { model: string; where?: any[]; limit?: number; offset?: number; sortBy?: any; join?: any }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
let query: any = c.db.select().from(table);
if (predicate) {
query = query.where(predicate);
}
if (input.sortBy?.field) {
const column = columnFor(input.model, table, input.sortBy.field);
query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column));
}
if (typeof input.limit === "number") {
query = query.limit(input.limit);
}
if (typeof input.offset === "number") {
query = query.offset(input.offset);
}
const rows = await query.all();
return await applyJoinToRows(c, input.model, rows, input.join);
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async betterAuthCountRecords(c, input: { model: string; where?: any[] }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
const row = predicate
? await c.db.select({ value: sqlCount() }).from(table).where(predicate).get()
: await c.db.select({ value: sqlCount() }).from(table).get();
return row?.value ?? 0;
},
};

View file

@ -0,0 +1,44 @@
import { eq } from "drizzle-orm";
import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "../db/schema.js";
import { materializeRow } from "../query-helpers.js";
export const userActions = {
// Custom Foundry action — not part of Better Auth.
async getAppAuthState(c, input: { sessionId: string }) {
const session = await c.db.select().from(authSessions).where(eq(authSessions.id, input.sessionId)).get();
if (!session) {
return null;
}
const [user, profile, currentSessionState, accounts] = await Promise.all([
c.db.select().from(authUsers).where(eq(authUsers.authUserId, session.userId)).get(),
c.db.select().from(userProfiles).where(eq(userProfiles.userId, session.userId)).get(),
c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get(),
c.db.select().from(authAccounts).where(eq(authAccounts.userId, session.userId)).all(),
]);
return {
session,
user: materializeRow("user", user),
profile: profile ?? null,
sessionState: currentSessionState ?? null,
accounts,
};
},
// Custom Foundry action — not part of Better Auth.
async getTaskState(c, input: { taskId: string }) {
const rows = await c.db.select().from(userTaskState).where(eq(userTaskState.taskId, input.taskId)).all();
const activeSessionId = rows.find((row) => typeof row.activeSessionId === "string" && row.activeSessionId.length > 0)?.activeSessionId ?? null;
return {
taskId: input.taskId,
activeSessionId,
sessions: rows.map((row) => ({
sessionId: row.sessionId,
unread: row.unread === 1,
draftText: row.draftText,
draftAttachmentsJson: row.draftAttachmentsJson,
draftUpdatedAt: row.draftUpdatedAt ?? null,
updatedAt: row.updatedAt,
})),
};
},
};

View file

@ -23,15 +23,19 @@ export default {
journal,
migrations: {
m0000: `CREATE TABLE \`user\` (
\`id\` text PRIMARY KEY NOT NULL,
\`id\` integer PRIMARY KEY NOT NULL,
\`auth_user_id\` text NOT NULL,
\`name\` text NOT NULL,
\`email\` text NOT NULL,
\`email_verified\` integer NOT NULL,
\`image\` text,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
\`updated_at\` integer NOT NULL,
CONSTRAINT \`user_singleton_id_check\` CHECK(\`id\` = 1)
);
--> statement-breakpoint
CREATE UNIQUE INDEX \`user_auth_user_id_idx\` ON \`user\` (\`auth_user_id\`);
--> statement-breakpoint
CREATE TABLE \`session\` (
\`id\` text PRIMARY KEY NOT NULL,
\`token\` text NOT NULL,
@ -69,7 +73,7 @@ CREATE TABLE \`user_profiles\` (
\`github_account_id\` text,
\`github_login\` text,
\`role_label\` text NOT NULL,
\`default_model\` text DEFAULT 'claude-sonnet-4' NOT NULL,
\`default_model\` text DEFAULT 'gpt-5.3-codex' NOT NULL,
\`eligible_organization_ids_json\` text NOT NULL,
\`starter_repo_status\` text NOT NULL,
\`starter_repo_starred_at\` integer,

View file

@ -1,16 +1,25 @@
import { check, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared";
/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */
export const authUsers = sqliteTable("user", {
id: text("id").notNull().primaryKey(),
name: text("name").notNull(),
email: text("email").notNull(),
emailVerified: integer("email_verified").notNull(),
image: text("image"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const authUsers = sqliteTable(
"user",
{
id: integer("id").primaryKey(),
authUserId: text("auth_user_id").notNull(),
name: text("name").notNull(),
email: text("email").notNull(),
emailVerified: integer("email_verified").notNull(),
image: text("image"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
},
(table) => ({
authUserIdIdx: uniqueIndex("user_auth_user_id_idx").on(table.authUserId),
singletonCheck: check("user_singleton_id_check", sql`${table.id} = 1`),
}),
);
/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */
export const authSessions = sqliteTable(
@ -62,7 +71,7 @@ export const userProfiles = sqliteTable(
githubAccountId: text("github_account_id"),
githubLogin: text("github_login"),
roleLabel: text("role_label").notNull(),
defaultModel: text("default_model").notNull().default("claude-sonnet-4"),
defaultModel: text("default_model").notNull().default(DEFAULT_WORKSPACE_MODEL_ID),
eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(),
starterRepoStatus: text("starter_repo_status").notNull(),
starterRepoStarredAt: integer("starter_repo_starred_at"),

View file

@ -1,158 +1,13 @@
import { and, asc, count as sqlCount, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, notInArray, or } from "drizzle-orm";
import { actor } from "rivetkit";
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { userDb } from "./db/db.js";
import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js";
const tables = {
user: authUsers,
session: authSessions,
account: authAccounts,
userProfiles,
sessionState,
userTaskState,
} as const;
function tableFor(model: string) {
const table = tables[model as keyof typeof tables];
if (!table) {
throw new Error(`Unsupported user model: ${model}`);
}
return table as any;
}
function columnFor(table: any, field: string) {
const column = table[field];
if (!column) {
throw new Error(`Unsupported user field: ${field}`);
}
return column;
}
function normalizeValue(value: unknown): unknown {
if (value instanceof Date) {
return value.getTime();
}
if (Array.isArray(value)) {
return value.map((entry) => normalizeValue(entry));
}
return value;
}
function clauseToExpr(table: any, clause: any) {
const column = columnFor(table, clause.field);
const value = normalizeValue(clause.value);
switch (clause.operator) {
case "ne":
return value === null ? isNotNull(column) : ne(column, value as any);
case "lt":
return lt(column, value as any);
case "lte":
return lte(column, value as any);
case "gt":
return gt(column, value as any);
case "gte":
return gte(column, value as any);
case "in":
return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "not_in":
return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "contains":
return like(column, `%${String(value ?? "")}%`);
case "starts_with":
return like(column, `${String(value ?? "")}%`);
case "ends_with":
return like(column, `%${String(value ?? "")}`);
case "eq":
default:
return value === null ? isNull(column) : eq(column, value as any);
}
}
function buildWhere(table: any, where: any[] | undefined) {
if (!where || where.length === 0) {
return undefined;
}
let expr = clauseToExpr(table, where[0]);
for (const clause of where.slice(1)) {
const next = clauseToExpr(table, clause);
expr = clause.connector === "OR" ? or(expr, next) : and(expr, next);
}
return expr;
}
function applyJoinToRow(c: any, model: string, row: any, join: any) {
if (!row || !join) {
return row;
}
if (model === "session" && join.user) {
return c.db
.select()
.from(authUsers)
.where(eq(authUsers.id, row.userId))
.get()
.then((user: any) => ({ ...row, user: user ?? null }));
}
if (model === "account" && join.user) {
return c.db
.select()
.from(authUsers)
.where(eq(authUsers.id, row.userId))
.get()
.then((user: any) => ({ ...row, user: user ?? null }));
}
if (model === "user" && join.account) {
return c.db
.select()
.from(authAccounts)
.where(eq(authAccounts.userId, row.id))
.all()
.then((accounts: any[]) => ({ ...row, account: accounts }));
}
return Promise.resolve(row);
}
async function applyJoinToRows(c: any, model: string, rows: any[], join: any) {
if (!join || rows.length === 0) {
return rows;
}
if (model === "session" && join.user) {
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.id, userIds)).all() : [];
const userMap = new Map(users.map((user: any) => [user.id, user]));
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
}
if (model === "account" && join.user) {
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.id, userIds)).all() : [];
const userMap = new Map(users.map((user: any) => [user.id, user]));
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
}
if (model === "user" && join.account) {
const userIds = rows.map((row) => row.id);
const accounts = userIds.length > 0 ? await c.db.select().from(authAccounts).where(inArray(authAccounts.userId, userIds)).all() : [];
const accountsByUserId = new Map<string, any[]>();
for (const account of accounts) {
const entries = accountsByUserId.get(account.userId) ?? [];
entries.push(account);
accountsByUserId.set(account.userId, entries);
}
return rows.map((row) => ({ ...row, account: accountsByUserId.get(row.id) ?? [] }));
}
return rows;
}
import { betterAuthActions } from "./actions/better-auth.js";
import { userActions } from "./actions/user.js";
import { USER_QUEUE_NAMES, runUserWorkflow } from "./workflow.js";
export const user = actor({
db: userDb,
queues: Object.fromEntries(USER_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
name: "User",
icon: "shield",
@ -162,312 +17,8 @@ export const user = actor({
userId: input.userId,
}),
actions: {
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async createAuthRecord(c, input: { model: string; data: Record<string, unknown> }) {
const table = tableFor(input.model);
await c.db
.insert(table)
.values(input.data as any)
.run();
return await c.db
.select()
.from(table)
.where(eq(columnFor(table, "id"), input.data.id as any))
.get();
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async findOneAuthRecord(c, input: { model: string; where: any[]; join?: any }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
const row = predicate ? await c.db.select().from(table).where(predicate).get() : await c.db.select().from(table).get();
return await applyJoinToRow(c, input.model, row ?? null, input.join);
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async findManyAuthRecords(c, input: { model: string; where?: any[]; limit?: number; offset?: number; sortBy?: any; join?: any }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
let query: any = c.db.select().from(table);
if (predicate) {
query = query.where(predicate);
}
if (input.sortBy?.field) {
const column = columnFor(table, input.sortBy.field);
query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column));
}
if (typeof input.limit === "number") {
query = query.limit(input.limit);
}
if (typeof input.offset === "number") {
query = query.offset(input.offset);
}
const rows = await query.all();
return await applyJoinToRows(c, input.model, rows, input.join);
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async updateAuthRecord(c, input: { model: string; where: any[]; update: Record<string, unknown> }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("updateAuthRecord requires a where clause");
}
await c.db
.update(table)
.set(input.update as any)
.where(predicate)
.run();
return await c.db.select().from(table).where(predicate).get();
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async updateManyAuthRecords(c, input: { model: string; where: any[]; update: Record<string, unknown> }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("updateManyAuthRecords requires a where clause");
}
await c.db
.update(table)
.set(input.update as any)
.where(predicate)
.run();
const row = await c.db.select({ value: sqlCount() }).from(table).where(predicate).get();
return row?.value ?? 0;
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async deleteAuthRecord(c, input: { model: string; where: any[] }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("deleteAuthRecord requires a where clause");
}
await c.db.delete(table).where(predicate).run();
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async deleteManyAuthRecords(c, input: { model: string; where: any[] }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("deleteManyAuthRecords requires a where clause");
}
const rows = await c.db.select().from(table).where(predicate).all();
await c.db.delete(table).where(predicate).run();
return rows.length;
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async countAuthRecords(c, input: { model: string; where?: any[] }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
const row = predicate
? await c.db.select({ value: sqlCount() }).from(table).where(predicate).get()
: await c.db.select({ value: sqlCount() }).from(table).get();
return row?.value ?? 0;
},
// Custom Foundry action — not part of Better Auth.
async getAppAuthState(c, input: { sessionId: string }) {
const session = await c.db.select().from(authSessions).where(eq(authSessions.id, input.sessionId)).get();
if (!session) {
return null;
}
const [user, profile, currentSessionState, accounts] = await Promise.all([
c.db.select().from(authUsers).where(eq(authUsers.id, session.userId)).get(),
c.db.select().from(userProfiles).where(eq(userProfiles.userId, session.userId)).get(),
c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get(),
c.db.select().from(authAccounts).where(eq(authAccounts.userId, session.userId)).all(),
]);
return {
session,
user,
profile: profile ?? null,
sessionState: currentSessionState ?? null,
accounts,
};
},
// Custom Foundry action — not part of Better Auth.
async upsertUserProfile(
c,
input: {
userId: string;
patch: {
githubAccountId?: string | null;
githubLogin?: string | null;
roleLabel?: string;
defaultModel?: string;
eligibleOrganizationIdsJson?: string;
starterRepoStatus?: string;
starterRepoStarredAt?: number | null;
starterRepoSkippedAt?: number | null;
};
},
) {
const now = Date.now();
await c.db
.insert(userProfiles)
.values({
id: 1,
userId: input.userId,
githubAccountId: input.patch.githubAccountId ?? null,
githubLogin: input.patch.githubLogin ?? null,
roleLabel: input.patch.roleLabel ?? "GitHub user",
defaultModel: input.patch.defaultModel ?? "claude-sonnet-4",
eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]",
starterRepoStatus: input.patch.starterRepoStatus ?? "pending",
starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null,
starterRepoSkippedAt: input.patch.starterRepoSkippedAt ?? null,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: userProfiles.userId,
set: {
...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}),
...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}),
...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}),
...(input.patch.defaultModel !== undefined ? { defaultModel: input.patch.defaultModel } : {}),
...(input.patch.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}),
...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}),
...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}),
...(input.patch.starterRepoSkippedAt !== undefined ? { starterRepoSkippedAt: input.patch.starterRepoSkippedAt } : {}),
updatedAt: now,
},
})
.run();
return await c.db.select().from(userProfiles).where(eq(userProfiles.userId, input.userId)).get();
},
// Custom Foundry action — not part of Better Auth.
async upsertSessionState(c, input: { sessionId: string; activeOrganizationId: string | null }) {
const now = Date.now();
await c.db
.insert(sessionState)
.values({
sessionId: input.sessionId,
activeOrganizationId: input.activeOrganizationId,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: sessionState.sessionId,
set: {
activeOrganizationId: input.activeOrganizationId,
updatedAt: now,
},
})
.run();
return await c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get();
},
// Custom Foundry action — not part of Better Auth.
async getTaskState(c, input: { taskId: string }) {
const rows = await c.db.select().from(userTaskState).where(eq(userTaskState.taskId, input.taskId)).all();
const activeSessionId = rows.find((row) => typeof row.activeSessionId === "string" && row.activeSessionId.length > 0)?.activeSessionId ?? null;
return {
taskId: input.taskId,
activeSessionId,
sessions: rows.map((row) => ({
sessionId: row.sessionId,
unread: row.unread === 1,
draftText: row.draftText,
draftAttachmentsJson: row.draftAttachmentsJson,
draftUpdatedAt: row.draftUpdatedAt ?? null,
updatedAt: row.updatedAt,
})),
};
},
// Custom Foundry action — not part of Better Auth.
async upsertTaskState(
c,
input: {
taskId: string;
sessionId: string;
patch: {
activeSessionId?: string | null;
unread?: boolean;
draftText?: string;
draftAttachmentsJson?: string;
draftUpdatedAt?: number | null;
};
},
) {
const now = Date.now();
const existing = await c.db
.select()
.from(userTaskState)
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
.get();
if (input.patch.activeSessionId !== undefined) {
await c.db
.update(userTaskState)
.set({
activeSessionId: input.patch.activeSessionId,
updatedAt: now,
})
.where(eq(userTaskState.taskId, input.taskId))
.run();
}
await c.db
.insert(userTaskState)
.values({
taskId: input.taskId,
sessionId: input.sessionId,
activeSessionId: input.patch.activeSessionId ?? existing?.activeSessionId ?? null,
unread: input.patch.unread !== undefined ? (input.patch.unread ? 1 : 0) : (existing?.unread ?? 0),
draftText: input.patch.draftText ?? existing?.draftText ?? "",
draftAttachmentsJson: input.patch.draftAttachmentsJson ?? existing?.draftAttachmentsJson ?? "[]",
draftUpdatedAt: input.patch.draftUpdatedAt === undefined ? (existing?.draftUpdatedAt ?? null) : input.patch.draftUpdatedAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [userTaskState.taskId, userTaskState.sessionId],
set: {
...(input.patch.activeSessionId !== undefined ? { activeSessionId: input.patch.activeSessionId } : {}),
...(input.patch.unread !== undefined ? { unread: input.patch.unread ? 1 : 0 } : {}),
...(input.patch.draftText !== undefined ? { draftText: input.patch.draftText } : {}),
...(input.patch.draftAttachmentsJson !== undefined ? { draftAttachmentsJson: input.patch.draftAttachmentsJson } : {}),
...(input.patch.draftUpdatedAt !== undefined ? { draftUpdatedAt: input.patch.draftUpdatedAt } : {}),
updatedAt: now,
},
})
.run();
return await c.db
.select()
.from(userTaskState)
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
.get();
},
// Custom Foundry action — not part of Better Auth.
async deleteTaskState(c, input: { taskId: string; sessionId?: string }) {
if (input.sessionId) {
await c.db
.delete(userTaskState)
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
.run();
return;
}
await c.db.delete(userTaskState).where(eq(userTaskState.taskId, input.taskId)).run();
},
...betterAuthActions,
...userActions,
},
run: workflow(runUserWorkflow),
});

View file

@ -0,0 +1,197 @@
import { and, eq, inArray, isNotNull, isNull, like, lt, lte, gt, gte, ne, notInArray, or } from "drizzle-orm";
import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js";
export const userTables = {
user: authUsers,
session: authSessions,
account: authAccounts,
userProfiles,
sessionState,
userTaskState,
} as const;
export function tableFor(model: string) {
const table = userTables[model as keyof typeof userTables];
if (!table) {
throw new Error(`Unsupported user model: ${model}`);
}
return table as any;
}
function dbFieldFor(model: string, field: string): string {
if (model === "user" && field === "id") {
return "authUserId";
}
return field;
}
export function materializeRow(model: string, row: any) {
if (!row || model !== "user") {
return row;
}
const { id: _singletonId, authUserId, ...rest } = row;
return {
id: authUserId,
...rest,
};
}
export function persistInput(model: string, data: Record<string, unknown>) {
if (model !== "user") {
return data;
}
const { id, ...rest } = data;
return {
id: 1,
authUserId: id,
...rest,
};
}
export function persistPatch(model: string, data: Record<string, unknown>) {
if (model !== "user") {
return data;
}
const { id, ...rest } = data;
return {
...(id !== undefined ? { authUserId: id } : {}),
...rest,
};
}
export function columnFor(model: string, table: any, field: string) {
const column = table[dbFieldFor(model, field)];
if (!column) {
throw new Error(`Unsupported user field: ${model}.${field}`);
}
return column;
}
export function normalizeValue(value: unknown): unknown {
if (value instanceof Date) {
return value.getTime();
}
if (Array.isArray(value)) {
return value.map((entry) => normalizeValue(entry));
}
return value;
}
export function clauseToExpr(table: any, clause: any) {
const model = table === authUsers ? "user" : table === authSessions ? "session" : table === authAccounts ? "account" : "";
const column = columnFor(model, table, clause.field);
const value = normalizeValue(clause.value);
switch (clause.operator) {
case "ne":
return value === null ? isNotNull(column) : ne(column, value as any);
case "lt":
return lt(column, value as any);
case "lte":
return lte(column, value as any);
case "gt":
return gt(column, value as any);
case "gte":
return gte(column, value as any);
case "in":
return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "not_in":
return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "contains":
return like(column, `%${String(value ?? "")}%`);
case "starts_with":
return like(column, `${String(value ?? "")}%`);
case "ends_with":
return like(column, `%${String(value ?? "")}`);
case "eq":
default:
return value === null ? isNull(column) : eq(column, value as any);
}
}
export function buildWhere(table: any, where: any[] | undefined) {
if (!where || where.length === 0) {
return undefined;
}
let expr = clauseToExpr(table, where[0]);
for (const clause of where.slice(1)) {
const next = clauseToExpr(table, clause);
expr = clause.connector === "OR" ? or(expr, next) : and(expr, next);
}
return expr;
}
export function applyJoinToRow(c: any, model: string, row: any, join: any) {
const materialized = materializeRow(model, row);
if (!materialized || !join) {
return materialized;
}
if (model === "session" && join.user) {
return c.db
.select()
.from(authUsers)
.where(eq(authUsers.authUserId, materialized.userId))
.get()
.then((user: any) => ({ ...materialized, user: materializeRow("user", user) ?? null }));
}
if (model === "account" && join.user) {
return c.db
.select()
.from(authUsers)
.where(eq(authUsers.authUserId, materialized.userId))
.get()
.then((user: any) => ({ ...materialized, user: materializeRow("user", user) ?? null }));
}
if (model === "user" && join.account) {
return c.db
.select()
.from(authAccounts)
.where(eq(authAccounts.userId, materialized.id))
.all()
.then((accounts: any[]) => ({ ...materialized, account: accounts }));
}
return Promise.resolve(materialized);
}
export async function applyJoinToRows(c: any, model: string, rows: any[], join: any) {
if (!join || rows.length === 0) {
return rows.map((row) => materializeRow(model, row));
}
if (model === "session" && join.user) {
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.authUserId, userIds)).all() : [];
const userMap = new Map(users.map((user: any) => [user.authUserId, materializeRow("user", user)]));
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
}
if (model === "account" && join.user) {
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.authUserId, userIds)).all() : [];
const userMap = new Map(users.map((user: any) => [user.authUserId, materializeRow("user", user)]));
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
}
if (model === "user" && join.account) {
const materializedRows = rows.map((row) => materializeRow("user", row));
const userIds = materializedRows.map((row) => row.id);
const accounts = userIds.length > 0 ? await c.db.select().from(authAccounts).where(inArray(authAccounts.userId, userIds)).all() : [];
const accountsByUserId = new Map<string, any[]>();
for (const account of accounts) {
const entries = accountsByUserId.get(account.userId) ?? [];
entries.push(account);
accountsByUserId.set(account.userId, entries);
}
return materializedRows.map((row) => ({ ...row, account: accountsByUserId.get(row.id) ?? [] }));
}
return rows.map((row) => materializeRow(model, row));
}

View file

@ -0,0 +1,281 @@
import { eq, count as sqlCount, and } from "drizzle-orm";
import { Loop } from "rivetkit/workflow";
import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared";
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import { authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js";
import { buildWhere, columnFor, materializeRow, persistInput, persistPatch, tableFor } from "./query-helpers.js";
export const USER_QUEUE_NAMES = [
"user.command.auth.create",
"user.command.auth.update",
"user.command.auth.update_many",
"user.command.auth.delete",
"user.command.auth.delete_many",
"user.command.profile.upsert",
"user.command.session_state.upsert",
"user.command.task_state.upsert",
"user.command.task_state.delete",
] as const;
export type UserQueueName = (typeof USER_QUEUE_NAMES)[number];
export function userWorkflowQueueName(name: UserQueueName): UserQueueName {
return name;
}
async function createAuthRecordMutation(c: any, input: { model: string; data: Record<string, unknown> }) {
const table = tableFor(input.model);
const persisted = persistInput(input.model, input.data);
await c.db.insert(table).values(persisted as any).run();
const row = await c.db.select().from(table).where(eq(columnFor(input.model, table, "id"), input.data.id as any)).get();
return materializeRow(input.model, row);
}
async function updateAuthRecordMutation(c: any, input: { model: string; where: any[]; update: Record<string, unknown> }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("updateAuthRecord requires a where clause");
}
await c.db.update(table).set(persistPatch(input.model, input.update) as any).where(predicate).run();
return materializeRow(input.model, await c.db.select().from(table).where(predicate).get());
}
async function updateManyAuthRecordsMutation(c: any, input: { model: string; where: any[]; update: Record<string, unknown> }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("updateManyAuthRecords requires a where clause");
}
await c.db.update(table).set(persistPatch(input.model, input.update) as any).where(predicate).run();
const row = await c.db.select({ value: sqlCount() }).from(table).where(predicate).get();
return row?.value ?? 0;
}
async function deleteAuthRecordMutation(c: any, input: { model: string; where: any[] }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("deleteAuthRecord requires a where clause");
}
await c.db.delete(table).where(predicate).run();
}
async function deleteManyAuthRecordsMutation(c: any, input: { model: string; where: any[] }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("deleteManyAuthRecords requires a where clause");
}
const rows = await c.db.select().from(table).where(predicate).all();
await c.db.delete(table).where(predicate).run();
return rows.length;
}
async function upsertUserProfileMutation(
c: any,
input: {
userId: string;
patch: {
githubAccountId?: string | null;
githubLogin?: string | null;
roleLabel?: string;
defaultModel?: string;
eligibleOrganizationIdsJson?: string;
starterRepoStatus?: string;
starterRepoStarredAt?: number | null;
starterRepoSkippedAt?: number | null;
};
},
) {
const now = Date.now();
await c.db
.insert(userProfiles)
.values({
id: 1,
userId: input.userId,
githubAccountId: input.patch.githubAccountId ?? null,
githubLogin: input.patch.githubLogin ?? null,
roleLabel: input.patch.roleLabel ?? "GitHub user",
defaultModel: input.patch.defaultModel ?? DEFAULT_WORKSPACE_MODEL_ID,
eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]",
starterRepoStatus: input.patch.starterRepoStatus ?? "pending",
starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null,
starterRepoSkippedAt: input.patch.starterRepoSkippedAt ?? null,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: userProfiles.userId,
set: {
...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}),
...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}),
...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}),
...(input.patch.defaultModel !== undefined ? { defaultModel: input.patch.defaultModel } : {}),
...(input.patch.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}),
...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}),
...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}),
...(input.patch.starterRepoSkippedAt !== undefined ? { starterRepoSkippedAt: input.patch.starterRepoSkippedAt } : {}),
updatedAt: now,
},
})
.run();
return await c.db.select().from(userProfiles).where(eq(userProfiles.userId, input.userId)).get();
}
async function upsertSessionStateMutation(c: any, input: { sessionId: string; activeOrganizationId: string | null }) {
const now = Date.now();
await c.db
.insert(sessionState)
.values({
sessionId: input.sessionId,
activeOrganizationId: input.activeOrganizationId,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: sessionState.sessionId,
set: {
activeOrganizationId: input.activeOrganizationId,
updatedAt: now,
},
})
.run();
return await c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get();
}
async function upsertTaskStateMutation(
c: any,
input: {
taskId: string;
sessionId: string;
patch: {
activeSessionId?: string | null;
unread?: boolean;
draftText?: string;
draftAttachmentsJson?: string;
draftUpdatedAt?: number | null;
};
},
) {
const now = Date.now();
const existing = await c.db
.select()
.from(userTaskState)
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
.get();
if (input.patch.activeSessionId !== undefined) {
await c.db
.update(userTaskState)
.set({
activeSessionId: input.patch.activeSessionId,
updatedAt: now,
})
.where(eq(userTaskState.taskId, input.taskId))
.run();
}
await c.db
.insert(userTaskState)
.values({
taskId: input.taskId,
sessionId: input.sessionId,
activeSessionId: input.patch.activeSessionId ?? existing?.activeSessionId ?? null,
unread: input.patch.unread !== undefined ? (input.patch.unread ? 1 : 0) : (existing?.unread ?? 0),
draftText: input.patch.draftText ?? existing?.draftText ?? "",
draftAttachmentsJson: input.patch.draftAttachmentsJson ?? existing?.draftAttachmentsJson ?? "[]",
draftUpdatedAt: input.patch.draftUpdatedAt === undefined ? (existing?.draftUpdatedAt ?? null) : input.patch.draftUpdatedAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [userTaskState.taskId, userTaskState.sessionId],
set: {
...(input.patch.activeSessionId !== undefined ? { activeSessionId: input.patch.activeSessionId } : {}),
...(input.patch.unread !== undefined ? { unread: input.patch.unread ? 1 : 0 } : {}),
...(input.patch.draftText !== undefined ? { draftText: input.patch.draftText } : {}),
...(input.patch.draftAttachmentsJson !== undefined ? { draftAttachmentsJson: input.patch.draftAttachmentsJson } : {}),
...(input.patch.draftUpdatedAt !== undefined ? { draftUpdatedAt: input.patch.draftUpdatedAt } : {}),
updatedAt: now,
},
})
.run();
return await c.db
.select()
.from(userTaskState)
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
.get();
}
async function deleteTaskStateMutation(c: any, input: { taskId: string; sessionId?: string }) {
if (input.sessionId) {
await c.db
.delete(userTaskState)
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
.run();
return;
}
await c.db.delete(userTaskState).where(eq(userTaskState.taskId, input.taskId)).run();
}
export async function runUserWorkflow(ctx: any): Promise<void> {
await ctx.loop("user-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-user-command", {
names: [...USER_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
try {
let result: unknown;
switch (msg.name) {
case "user.command.auth.create":
result = await loopCtx.step({ name: "user-auth-create", timeout: 60_000, run: async () => createAuthRecordMutation(loopCtx, msg.body) });
break;
case "user.command.auth.update":
result = await loopCtx.step({ name: "user-auth-update", timeout: 60_000, run: async () => updateAuthRecordMutation(loopCtx, msg.body) });
break;
case "user.command.auth.update_many":
result = await loopCtx.step({ name: "user-auth-update-many", timeout: 60_000, run: async () => updateManyAuthRecordsMutation(loopCtx, msg.body) });
break;
case "user.command.auth.delete":
result = await loopCtx.step({ name: "user-auth-delete", timeout: 60_000, run: async () => deleteAuthRecordMutation(loopCtx, msg.body) });
break;
case "user.command.auth.delete_many":
result = await loopCtx.step({ name: "user-auth-delete-many", timeout: 60_000, run: async () => deleteManyAuthRecordsMutation(loopCtx, msg.body) });
break;
case "user.command.profile.upsert":
result = await loopCtx.step({ name: "user-profile-upsert", timeout: 60_000, run: async () => upsertUserProfileMutation(loopCtx, msg.body) });
break;
case "user.command.session_state.upsert":
result = await loopCtx.step({ name: "user-session-state-upsert", timeout: 60_000, run: async () => upsertSessionStateMutation(loopCtx, msg.body) });
break;
case "user.command.task_state.upsert":
result = await loopCtx.step({ name: "user-task-state-upsert", timeout: 60_000, run: async () => upsertTaskStateMutation(loopCtx, msg.body) });
break;
case "user.command.task_state.delete":
result = await loopCtx.step({ name: "user-task-state-delete", timeout: 60_000, run: async () => deleteTaskStateMutation(loopCtx, msg.body) });
break;
default:
return Loop.continue(undefined);
}
await msg.complete(result);
} catch (error) {
const message = resolveErrorMessage(error);
logActorWarning("user", "user workflow command failed", {
queueName: msg.name,
error: message,
});
await msg.complete({ error: message }).catch(() => {});
}
return Loop.continue(undefined);
});
}

View file

@ -10,7 +10,7 @@ import { createDefaultDriver } from "./driver.js";
import { createClient } from "rivetkit/client";
import { initBetterAuthService } from "./services/better-auth.js";
import { createDefaultAppShellServices } from "./services/app-shell-runtime.js";
import { APP_SHELL_ORGANIZATION_ID } from "./actors/organization/app-shell.js";
import { APP_SHELL_ORGANIZATION_ID } from "./actors/organization/constants.js";
import { logger } from "./logging.js";
export interface BackendStartOptions {

View file

@ -1,8 +1,11 @@
import { betterAuth } from "better-auth";
import { createAdapterFactory } from "better-auth/adapters";
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/app-shell.js";
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/constants.js";
import { organizationWorkflowQueueName } from "../actors/organization/queues.js";
import { userWorkflowQueueName } from "../actors/user/workflow.js";
import { organizationKey, userKey } from "../actors/keys.js";
import { logger } from "../logging.js";
import { expectQueueResponse } from "./queue.js";
const AUTH_BASE_PATH = "/v1/auth";
const SESSION_COOKIE = "better-auth.session_token";
@ -59,6 +62,12 @@ function resolveRouteUserId(organization: any, resolved: any): string | null {
return null;
}
async function sendOrganizationCommand<TResponse>(organization: any, name: Parameters<typeof organizationWorkflowQueueName>[0], body: unknown): Promise<TResponse> {
return expectQueueResponse<TResponse>(
await organization.send(organizationWorkflowQueueName(name), body, { wait: true, timeout: 60_000 }),
);
}
export interface BetterAuthService {
auth: any;
resolveSession(headers: Headers): Promise<{ session: any; user: any } | null>;
@ -110,7 +119,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const email = direct("email");
if (typeof email === "string" && email.length > 0) {
const organization = await appOrganization();
const resolved = await organization.authFindEmailIndex({ email: email.toLowerCase() });
const resolved = await organization.betterAuthFindEmailIndex({ email: email.toLowerCase() });
return resolveRouteUserId(organization, resolved);
}
return null;
@ -125,7 +134,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const sessionToken = direct("token") ?? data?.token;
if (typeof sessionId === "string" || typeof sessionToken === "string") {
const organization = await appOrganization();
const resolved = await organization.authFindSessionIndex({
const resolved = await organization.betterAuthFindSessionIndex({
...(typeof sessionId === "string" ? { sessionId } : {}),
...(typeof sessionToken === "string" ? { sessionToken } : {}),
});
@ -144,11 +153,11 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const accountId = direct("accountId") ?? data?.accountId;
const organization = await appOrganization();
if (typeof accountRecordId === "string" && accountRecordId.length > 0) {
const resolved = await organization.authFindAccountIndex({ id: accountRecordId });
const resolved = await organization.betterAuthFindAccountIndex({ id: accountRecordId });
return resolveRouteUserId(organization, resolved);
}
if (typeof providerId === "string" && providerId.length > 0 && typeof accountId === "string" && accountId.length > 0) {
const resolved = await organization.authFindAccountIndex({ providerId, accountId });
const resolved = await organization.betterAuthFindAccountIndex({ providerId, accountId });
return resolveRouteUserId(organization, resolved);
}
return null;
@ -157,9 +166,9 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return null;
};
const ensureOrganizationVerification = async (method: string, payload: Record<string, unknown>) => {
const ensureOrganizationVerification = async <TResponse>(method: Parameters<typeof organizationWorkflowQueueName>[0], payload: Record<string, unknown>) => {
const organization = await appOrganization();
return await organization[method](payload);
return await sendOrganizationCommand<TResponse>(organization, method, payload);
};
return {
@ -170,7 +179,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
create: async ({ model, data }) => {
const transformed = await transformInput(data, model, "create", true);
if (model === "verification") {
return await ensureOrganizationVerification("authCreateVerification", { data: transformed });
return await ensureOrganizationVerification<any>("organization.command.better_auth.verification.create", { data: transformed });
}
const userId = await resolveUserIdForQuery(model, undefined, transformed);
@ -179,18 +188,20 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
const created = await userActor.createAuthRecord({ model, data: transformed });
const created = expectQueueResponse<any>(
await userActor.send(userWorkflowQueueName("user.command.auth.create"), { model, data: transformed }, { wait: true, timeout: 60_000 }),
);
const organization = await appOrganization();
if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) {
await organization.authUpsertEmailIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.email_index.upsert", {
email: transformed.email.toLowerCase(),
userId,
});
}
if (model === "session") {
await organization.authUpsertSessionIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.session_index.upsert", {
sessionId: String(created.id),
sessionToken: String(created.token),
userId,
@ -198,7 +209,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
if (model === "account") {
await organization.authUpsertAccountIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.account_index.upsert", {
id: String(created.id),
providerId: String(created.providerId),
accountId: String(created.accountId),
@ -212,7 +223,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
findOne: async ({ model, where, join }) => {
const transformedWhere = transformWhereClause({ model, where, action: "findOne" });
if (model === "verification") {
return await ensureOrganizationVerification("authFindOneVerification", { where: transformedWhere, join });
const organization = await appOrganization();
return await organization.betterAuthFindOneVerification({ where: transformedWhere, join });
}
const userId = await resolveUserIdForQuery(model, transformedWhere);
@ -221,14 +233,15 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
const found = await userActor.findOneAuthRecord({ model, where: transformedWhere, join });
const found = await userActor.betterAuthFindOneRecord({ model, where: transformedWhere, join });
return found ? ((await transformOutput(found, model, undefined, join)) as any) : null;
},
findMany: async ({ model, where, limit, sortBy, offset, join }) => {
const transformedWhere = transformWhereClause({ model, where, action: "findMany" });
if (model === "verification") {
return await ensureOrganizationVerification("authFindManyVerification", {
const organization = await appOrganization();
return await organization.betterAuthFindManyVerification({
where: transformedWhere,
limit,
sortBy,
@ -244,7 +257,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const resolved = await Promise.all(
(tokenClause.value as string[]).map(async (sessionToken: string) => ({
sessionToken,
route: await organization.authFindSessionIndex({ sessionToken }),
route: await organization.betterAuthFindSessionIndex({ sessionToken }),
})),
);
const byUser = new Map<string, string[]>();
@ -263,7 +276,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const scopedWhere = transformedWhere.map((entry: any) =>
entry.field === "token" && entry.operator === "in" ? { ...entry, value: tokens } : entry,
);
const found = await userActor.findManyAuthRecords({ model, where: scopedWhere, limit, sortBy, offset, join });
const found = await userActor.betterAuthFindManyRecords({ model, where: scopedWhere, limit, sortBy, offset, join });
rows.push(...found);
}
return await Promise.all(rows.map(async (row: any) => await transformOutput(row, model, undefined, join)));
@ -276,7 +289,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
const found = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit, sortBy, offset, join });
const found = await userActor.betterAuthFindManyRecords({ model, where: transformedWhere, limit, sortBy, offset, join });
return await Promise.all(found.map(async (row: any) => await transformOutput(row, model, undefined, join)));
},
@ -284,7 +297,10 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const transformedWhere = transformWhereClause({ model, where, action: "update" });
const transformedUpdate = (await transformInput(update as Record<string, unknown>, model, "update", true)) as Record<string, unknown>;
if (model === "verification") {
return await ensureOrganizationVerification("authUpdateVerification", { where: transformedWhere, update: transformedUpdate });
return await ensureOrganizationVerification<any>("organization.command.better_auth.verification.update", {
where: transformedWhere,
update: transformedUpdate,
});
}
const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate);
@ -295,26 +311,37 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const userActor = await getUser(userId);
const before =
model === "user"
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
? await userActor.betterAuthFindOneRecord({ model, where: transformedWhere })
: model === "account"
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
? await userActor.betterAuthFindOneRecord({ model, where: transformedWhere })
: model === "session"
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
? await userActor.betterAuthFindOneRecord({ model, where: transformedWhere })
: null;
const updated = await userActor.updateAuthRecord({ model, where: transformedWhere, update: transformedUpdate });
const updated = expectQueueResponse<any>(
await userActor.send(
userWorkflowQueueName("user.command.auth.update"),
{ model, where: transformedWhere, update: transformedUpdate },
{ wait: true, timeout: 60_000 },
),
);
const organization = await appOrganization();
if (model === "user" && updated) {
if (before?.email && before.email !== updated.email) {
await organization.authDeleteEmailIndex({ email: before.email.toLowerCase() });
await sendOrganizationCommand(organization, "organization.command.better_auth.email_index.delete", {
email: before.email.toLowerCase(),
});
}
if (updated.email) {
await organization.authUpsertEmailIndex({ email: updated.email.toLowerCase(), userId });
await sendOrganizationCommand(organization, "organization.command.better_auth.email_index.upsert", {
email: updated.email.toLowerCase(),
userId,
});
}
}
if (model === "session" && updated) {
await organization.authUpsertSessionIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.session_index.upsert", {
sessionId: String(updated.id),
sessionToken: String(updated.token),
userId,
@ -322,7 +349,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
if (model === "account" && updated) {
await organization.authUpsertAccountIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.account_index.upsert", {
id: String(updated.id),
providerId: String(updated.providerId),
accountId: String(updated.accountId),
@ -337,7 +364,10 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const transformedWhere = transformWhereClause({ model, where, action: "updateMany" });
const transformedUpdate = (await transformInput(update as Record<string, unknown>, model, "update", true)) as Record<string, unknown>;
if (model === "verification") {
return await ensureOrganizationVerification("authUpdateManyVerification", { where: transformedWhere, update: transformedUpdate });
return await ensureOrganizationVerification<number>("organization.command.better_auth.verification.update_many", {
where: transformedWhere,
update: transformedUpdate,
});
}
const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate);
@ -346,13 +376,20 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
return await userActor.updateManyAuthRecords({ model, where: transformedWhere, update: transformedUpdate });
return expectQueueResponse<number>(
await userActor.send(
userWorkflowQueueName("user.command.auth.update_many"),
{ model, where: transformedWhere, update: transformedUpdate },
{ wait: true, timeout: 60_000 },
),
);
},
delete: async ({ model, where }) => {
const transformedWhere = transformWhereClause({ model, where, action: "delete" });
if (model === "verification") {
await ensureOrganizationVerification("authDeleteVerification", { where: transformedWhere });
const organization = await appOrganization();
await sendOrganizationCommand(organization, "organization.command.better_auth.verification.delete", { where: transformedWhere });
return;
}
@ -363,18 +400,20 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const userActor = await getUser(userId);
const organization = await appOrganization();
const before = await userActor.findOneAuthRecord({ model, where: transformedWhere });
await userActor.deleteAuthRecord({ model, where: transformedWhere });
const before = await userActor.betterAuthFindOneRecord({ model, where: transformedWhere });
expectQueueResponse<void>(
await userActor.send(userWorkflowQueueName("user.command.auth.delete"), { model, where: transformedWhere }, { wait: true, timeout: 60_000 }),
);
if (model === "session" && before) {
await organization.authDeleteSessionIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.session_index.delete", {
sessionId: before.id,
sessionToken: before.token,
});
}
if (model === "account" && before) {
await organization.authDeleteAccountIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.account_index.delete", {
id: before.id,
providerId: before.providerId,
accountId: before.accountId,
@ -382,14 +421,16 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
if (model === "user" && before?.email) {
await organization.authDeleteEmailIndex({ email: before.email.toLowerCase() });
await sendOrganizationCommand(organization, "organization.command.better_auth.email_index.delete", {
email: before.email.toLowerCase(),
});
}
},
deleteMany: async ({ model, where }) => {
const transformedWhere = transformWhereClause({ model, where, action: "deleteMany" });
if (model === "verification") {
return await ensureOrganizationVerification("authDeleteManyVerification", { where: transformedWhere });
return await ensureOrganizationVerification<number>("organization.command.better_auth.verification.delete_many", { where: transformedWhere });
}
if (model === "session") {
@ -399,10 +440,12 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
const organization = await appOrganization();
const sessions = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit: 5000 });
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
const sessions = await userActor.betterAuthFindManyRecords({ model, where: transformedWhere, limit: 5000 });
const deleted = expectQueueResponse<number>(
await userActor.send(userWorkflowQueueName("user.command.auth.delete_many"), { model, where: transformedWhere }, { wait: true, timeout: 60_000 }),
);
for (const session of sessions) {
await organization.authDeleteSessionIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.session_index.delete", {
sessionId: session.id,
sessionToken: session.token,
});
@ -416,14 +459,17 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
const deleted = expectQueueResponse<number>(
await userActor.send(userWorkflowQueueName("user.command.auth.delete_many"), { model, where: transformedWhere }, { wait: true, timeout: 60_000 }),
);
return deleted;
},
count: async ({ model, where }) => {
const transformedWhere = transformWhereClause({ model, where, action: "count" });
if (model === "verification") {
return await ensureOrganizationVerification("authCountVerification", { where: transformedWhere });
const organization = await appOrganization();
return await organization.betterAuthCountVerification({ where: transformedWhere });
}
const userId = await resolveUserIdForQuery(model, transformedWhere);
@ -432,7 +478,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
return await userActor.countAuthRecords({ model, where: transformedWhere });
return await userActor.betterAuthCountRecords({ model, where: transformedWhere });
},
};
},
@ -477,7 +523,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
async getAuthState(sessionId: string) {
const organization = await appOrganization();
const route = await organization.authFindSessionIndex({ sessionId });
const route = await organization.betterAuthFindSessionIndex({ sessionId });
if (!route?.userId) {
return null;
}
@ -487,7 +533,9 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
async upsertUserProfile(userId: string, patch: Record<string, unknown>) {
const userActor = await getUser(userId);
return await userActor.upsertUserProfile({ userId, patch });
return expectQueueResponse(
await userActor.send(userWorkflowQueueName("user.command.profile.upsert"), { userId, patch }, { wait: true, timeout: 60_000 }),
);
},
async setActiveOrganization(sessionId: string, activeOrganizationId: string | null) {
@ -496,7 +544,13 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
throw new Error(`Unknown auth session ${sessionId}`);
}
const userActor = await getUser(authState.user.id);
return await userActor.upsertSessionState({ sessionId, activeOrganizationId });
return expectQueueResponse(
await userActor.send(
userWorkflowQueueName("user.command.session_state.upsert"),
{ sessionId, activeOrganizationId },
{ wait: true, timeout: 60_000 },
),
);
},
async getAccessTokenForSession(sessionId: string) {

View file

@ -1,5 +1,5 @@
import { getOrCreateOrganization } from "../actors/handles.js";
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/app-shell.js";
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/constants.js";
export interface ResolvedGithubAuth {
githubToken: string;

View file

@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest";
import { setupTest } from "rivetkit/test";
import { organizationKey } from "../src/actors/keys.js";
import { registry } from "../src/actors/index.js";
import { organizationWorkflowQueueName } from "../src/actors/organization/queues.js";
import { repoIdFromRemote } from "../src/services/repo.js";
import { createTestDriver } from "./helpers/test-driver.js";
import { createTestRuntimeContext } from "./helpers/test-context.js";
@ -51,8 +52,8 @@ describe("organization isolation", () => {
const { repoPath } = createRepo();
const repoId = repoIdFromRemote(repoPath);
await wsA.applyGithubRepositoryProjection({ repoId, remoteUrl: repoPath });
await wsB.applyGithubRepositoryProjection({ repoId, remoteUrl: repoPath });
await wsA.send(organizationWorkflowQueueName("organization.command.github.repository_projection.apply"), { repoId, remoteUrl: repoPath }, { wait: true });
await wsB.send(organizationWorkflowQueueName("organization.command.github.repository_projection.apply"), { repoId, remoteUrl: repoPath }, { wait: true });
await wsA.createTask({
organizationId: "alpha",

View file

@ -1,4 +1,4 @@
import type { AppConfig, TaskRecord } from "@sandbox-agent/foundry-shared";
import type { AppConfig, TaskRecord, WorkspaceTaskDetail } from "@sandbox-agent/foundry-shared";
import { spawnSync } from "node:child_process";
import { createBackendClientFromConfig, filterTasks, formatRelativeAge, groupTaskStatus } from "@sandbox-agent/foundry-client";
import { CLI_BUILD_ID } from "./build-id.js";
@ -51,14 +51,28 @@ interface DisplayRow {
age: string;
}
type TuiTaskRow = TaskRecord & Pick<WorkspaceTaskDetail, "pullRequest"> & { activeSessionId?: string | null };
interface RenderOptions {
width?: number;
height?: number;
}
async function listDetailedTasks(client: ReturnType<typeof createBackendClientFromConfig>, organizationId: string): Promise<TaskRecord[]> {
async function listDetailedTasks(client: ReturnType<typeof createBackendClientFromConfig>, organizationId: string): Promise<TuiTaskRow[]> {
const rows = await client.listTasks(organizationId);
return await Promise.all(rows.map(async (row) => await client.getTask(organizationId, row.taskId)));
return await Promise.all(
rows.map(async (row) => {
const [task, detail] = await Promise.all([
client.getTask(organizationId, row.repoId, row.taskId),
client.getTaskDetail(organizationId, row.repoId, row.taskId).catch(() => null),
]);
return {
...task,
pullRequest: detail?.pullRequest ?? null,
activeSessionId: detail?.activeSessionId ?? null,
};
}),
);
}
function pad(input: string, width: number): string {
@ -143,29 +157,17 @@ function agentSymbol(status: TaskRecord["status"]): string {
return "-";
}
function toDisplayRow(row: TaskRecord): DisplayRow {
const conflictPrefix = row.conflictsWithMain === "true" ? "\u26A0 " : "";
const prLabel = row.prUrl ? `#${row.prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?"}` : row.prSubmitted ? "sub" : "-";
const ciLabel = row.ciStatus ?? "-";
const reviewLabel = row.reviewStatus
? row.reviewStatus === "approved"
? "ok"
: row.reviewStatus === "changes_requested"
? "chg"
: row.reviewStatus === "pending"
? "..."
: row.reviewStatus
: "-";
function toDisplayRow(row: TuiTaskRow): DisplayRow {
const prLabel = row.pullRequest ? `#${row.pullRequest.number}` : "-";
const reviewLabel = row.pullRequest ? (row.pullRequest.isDraft ? "draft" : row.pullRequest.state.toLowerCase()) : "-";
return {
name: `${conflictPrefix}${row.title || row.branchName}`,
diff: row.diffStat ?? "-",
name: row.title || row.branchName || row.taskId,
diff: "-",
agent: agentSymbol(row.status),
pr: prLabel,
author: row.prAuthor ?? "-",
ci: ciLabel,
author: row.pullRequest?.authorLogin ?? "-",
ci: "-",
review: reviewLabel,
age: formatRelativeAge(row.updatedAt),
};
@ -186,7 +188,7 @@ function helpLines(width: number): string[] {
}
export function formatRows(
rows: TaskRecord[],
rows: TuiTaskRow[],
selected: number,
organizationId: string,
status: string,
@ -336,8 +338,8 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
renderer.root.add(text);
renderer.start();
let allRows: TaskRecord[] = [];
let filteredRows: TaskRecord[] = [];
let allRows: TuiTaskRow[] = [];
let filteredRows: TuiTaskRow[] = [];
let selected = 0;
let searchQuery = "";
let showHelp = false;
@ -393,7 +395,7 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
render();
};
const selectedRow = (): TaskRecord | null => {
const selectedRow = (): TuiTaskRow | null => {
if (filteredRows.length === 0) {
return null;
}
@ -522,7 +524,7 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
render();
void (async () => {
try {
const result = await client.switchTask(organizationId, row.taskId);
const result = await client.switchTask(organizationId, row.repoId, row.taskId);
close(`cd ${result.switchTarget}`);
} catch (err) {
busy = false;
@ -543,7 +545,7 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
render();
void (async () => {
try {
const result = await client.attachTask(organizationId, row.taskId);
const result = await client.attachTask(organizationId, row.repoId, row.taskId);
close(`target=${result.target} session=${result.sessionId ?? "none"}`);
} catch (err) {
busy = false;
@ -559,7 +561,11 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
if (!row) {
return;
}
void runActionWithRefresh(`archiving ${row.taskId}`, async () => client.runAction(organizationId, row.taskId, "archive"), `archived ${row.taskId}`);
void runActionWithRefresh(
`archiving ${row.taskId}`,
async () => client.runAction(organizationId, row.repoId, row.taskId, "archive"),
`archived ${row.taskId}`,
);
return;
}
@ -568,7 +574,11 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
if (!row) {
return;
}
void runActionWithRefresh(`syncing ${row.taskId}`, async () => client.runAction(organizationId, row.taskId, "sync"), `synced ${row.taskId}`);
void runActionWithRefresh(
`syncing ${row.taskId}`,
async () => client.runAction(organizationId, row.repoId, row.taskId, "sync"),
`synced ${row.taskId}`,
);
return;
}
@ -580,8 +590,8 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
void runActionWithRefresh(
`merging ${row.taskId}`,
async () => {
await client.runAction(organizationId, row.taskId, "merge");
await client.runAction(organizationId, row.taskId, "archive");
await client.runAction(organizationId, row.repoId, row.taskId, "merge");
await client.runAction(organizationId, row.repoId, row.taskId, "archive");
},
`merged+archived ${row.taskId}`,
);
@ -590,14 +600,15 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
if (ctrl && name === "o") {
const row = selectedRow();
if (!row?.prUrl) {
const prUrl = row?.pullRequest?.url ?? null;
if (!prUrl) {
status = "no PR URL available for this task";
render();
return;
}
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
spawnSync(openCmd, [row.prUrl], { stdio: "ignore" });
status = `opened ${row.prUrl}`;
spawnSync(openCmd, [prUrl], { stdio: "ignore" });
status = `opened ${prUrl}`;
render();
return;
}

View file

@ -3,7 +3,7 @@ import type { TaskRecord } from "@sandbox-agent/foundry-shared";
import { filterTasks, fuzzyMatch } from "@sandbox-agent/foundry-client";
import { formatRows } from "../src/tui.js";
const sample: TaskRecord = {
const sample = {
organizationId: "default",
repoId: "repo-a",
repoRemote: "https://example.com/repo-a.git",
@ -13,33 +13,22 @@ const sample: TaskRecord = {
task: "Do test",
sandboxProviderId: "local",
status: "running",
statusMessage: null,
activeSandboxId: "sandbox-1",
activeSessionId: "session-1",
pullRequest: null,
sandboxes: [
{
sandboxId: "sandbox-1",
sandboxProviderId: "local",
sandboxActorId: null,
switchTarget: "sandbox://local/sandbox-1",
cwd: null,
createdAt: 1,
updatedAt: 1,
},
],
agentType: null,
prSubmitted: false,
diffStat: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: null,
hasUnpushed: null,
parentBranch: null,
createdAt: 1,
updatedAt: 1,
};
} satisfies TaskRecord & { pullRequest: null; activeSessionId?: null };
describe("formatRows", () => {
it("renders rust-style table header and empty state", () => {

View file

@ -37,6 +37,7 @@ import type {
StarSandboxAgentRepoResult,
SwitchResult,
UpdateFoundryOrganizationProfileInput,
WorkspaceModelGroup,
WorkspaceModelId,
} from "@sandbox-agent/foundry-shared";
import type { ProcessCreateRequest, ProcessInfo, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
@ -73,6 +74,10 @@ export interface ActorConn {
dispose(): Promise<void>;
}
interface AuthSessionScopedInput {
authSessionId?: string;
}
interface OrganizationHandle {
connect(): ActorConn;
listRepos(input: { organizationId: string }): Promise<RepoRecord[]>;
@ -91,24 +96,22 @@ interface OrganizationHandle {
useOrganization(input: { organizationId: string }): Promise<{ organizationId: string }>;
starSandboxAgentRepo(input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult>;
getOrganizationSummary(input: { organizationId: string }): Promise<OrganizationSummarySnapshot>;
adminReconcileWorkspaceState(input: { organizationId: string }): Promise<OrganizationSummarySnapshot>;
createWorkspaceTask(input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse>;
markWorkspaceUnread(input: TaskWorkspaceSelectInput): Promise<void>;
renameWorkspaceTask(input: TaskWorkspaceRenameInput): Promise<void>;
createWorkspaceSession(input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }>;
renameWorkspaceSession(input: TaskWorkspaceRenameSessionInput): Promise<void>;
setWorkspaceSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
updateWorkspaceDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void>;
changeWorkspaceModel(input: TaskWorkspaceChangeModelInput): Promise<void>;
sendWorkspaceMessage(input: TaskWorkspaceSendMessageInput): Promise<void>;
stopWorkspaceSession(input: TaskWorkspaceSessionInput): Promise<void>;
closeWorkspaceSession(input: TaskWorkspaceSessionInput): Promise<void>;
publishWorkspacePr(input: TaskWorkspaceSelectInput): Promise<void>;
revertWorkspaceFile(input: TaskWorkspaceDiffInput): Promise<void>;
createWorkspaceTask(input: TaskWorkspaceCreateTaskInput & AuthSessionScopedInput): Promise<TaskWorkspaceCreateTaskResponse>;
markWorkspaceUnread(input: TaskWorkspaceSelectInput & AuthSessionScopedInput): Promise<void>;
renameWorkspaceTask(input: TaskWorkspaceRenameInput & AuthSessionScopedInput): Promise<void>;
createWorkspaceSession(input: TaskWorkspaceSelectInput & { model?: string } & AuthSessionScopedInput): Promise<{ sessionId: string }>;
renameWorkspaceSession(input: TaskWorkspaceRenameSessionInput & AuthSessionScopedInput): Promise<void>;
selectWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
setWorkspaceSessionUnread(input: TaskWorkspaceSetSessionUnreadInput & AuthSessionScopedInput): Promise<void>;
updateWorkspaceDraft(input: TaskWorkspaceUpdateDraftInput & AuthSessionScopedInput): Promise<void>;
changeWorkspaceModel(input: TaskWorkspaceChangeModelInput & AuthSessionScopedInput): Promise<void>;
sendWorkspaceMessage(input: TaskWorkspaceSendMessageInput & AuthSessionScopedInput): Promise<void>;
stopWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
closeWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
publishWorkspacePr(input: TaskWorkspaceSelectInput & AuthSessionScopedInput): Promise<void>;
revertWorkspaceFile(input: TaskWorkspaceDiffInput & AuthSessionScopedInput): Promise<void>;
adminReloadGithubOrganization(): Promise<void>;
adminReloadGithubPullRequests(): Promise<void>;
adminReloadGithubRepository(input: { repoId: string }): Promise<void>;
adminReloadGithubPullRequest(input: { repoId: string; prNumber: number }): Promise<void>;
}
interface AppOrganizationHandle {
@ -130,8 +133,8 @@ interface AppOrganizationHandle {
interface TaskHandle {
getTaskSummary(): Promise<WorkspaceTaskSummary>;
getTaskDetail(): Promise<WorkspaceTaskDetail>;
getSessionDetail(input: { sessionId: string }): Promise<WorkspaceSessionDetail>;
getTaskDetail(input?: AuthSessionScopedInput): Promise<WorkspaceTaskDetail>;
getSessionDetail(input: { sessionId: string } & AuthSessionScopedInput): Promise<WorkspaceSessionDetail>;
connect(): ActorConn;
}
@ -156,6 +159,7 @@ interface TaskSandboxHandle {
rawSendSessionMethod(sessionId: string, method: string, params: Record<string, unknown>): Promise<unknown>;
destroySession(sessionId: string): Promise<void>;
sandboxAgentConnection(): Promise<{ endpoint: string; token?: string }>;
listWorkspaceModelGroups(): Promise<WorkspaceModelGroup[]>;
providerState(): Promise<{ sandboxProviderId: SandboxProviderId; sandboxId: string; state: string; at: number }>;
}
@ -279,6 +283,7 @@ export interface BackendClient {
sandboxId: string,
): Promise<{ sandboxProviderId: SandboxProviderId; sandboxId: string; state: string; at: number }>;
getSandboxAgentConnection(organizationId: string, sandboxProviderId: SandboxProviderId, sandboxId: string): Promise<{ endpoint: string; token?: string }>;
getSandboxWorkspaceModelGroups(organizationId: string, sandboxProviderId: SandboxProviderId, sandboxId: string): Promise<WorkspaceModelGroup[]>;
getOrganizationSummary(organizationId: string): Promise<OrganizationSummarySnapshot>;
getTaskDetail(organizationId: string, repoId: string, taskId: string): Promise<WorkspaceTaskDetail>;
getSessionDetail(organizationId: string, repoId: string, taskId: string, sessionId: string): Promise<WorkspaceSessionDetail>;
@ -289,6 +294,7 @@ export interface BackendClient {
renameWorkspaceTask(organizationId: string, input: TaskWorkspaceRenameInput): Promise<void>;
createWorkspaceSession(organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }>;
renameWorkspaceSession(organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise<void>;
selectWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void>;
setWorkspaceSessionUnread(organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
updateWorkspaceDraft(organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise<void>;
changeWorkspaceModel(organizationId: string, input: TaskWorkspaceChangeModelInput): Promise<void>;
@ -298,9 +304,7 @@ export interface BackendClient {
publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void>;
revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void>;
adminReloadGithubOrganization(organizationId: string): Promise<void>;
adminReloadGithubPullRequests(organizationId: string): Promise<void>;
adminReloadGithubRepository(organizationId: string, repoId: string): Promise<void>;
adminReloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise<void>;
health(): Promise<{ ok: true }>;
useOrganization(organizationId: string): Promise<{ organizationId: string }>;
starSandboxAgentRepo(organizationId: string): Promise<StarSandboxAgentRepoResult>;
@ -460,6 +464,16 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null;
};
const getAuthSessionInput = async (): Promise<AuthSessionScopedInput | undefined> => {
const authSessionId = await getSessionId();
return authSessionId ? { authSessionId } : undefined;
};
const withAuthSessionInput = async <TInput extends object>(input: TInput): Promise<TInput & AuthSessionScopedInput> => {
const authSessionInput = await getAuthSessionInput();
return authSessionInput ? { ...input, ...authSessionInput } : input;
};
const organization = async (organizationId: string): Promise<OrganizationHandle> =>
client.organization.getOrCreate(organizationKey(organizationId), {
createWithInput: organizationId,
@ -492,17 +506,18 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
for (const row of candidates) {
try {
const detail = await ws.getTask({ organizationId, taskId: row.taskId });
const detail = await ws.getTask({ organizationId, repoId: row.repoId, taskId: row.taskId });
if (detail.sandboxProviderId !== sandboxProviderId) {
continue;
}
const sandbox = detail.sandboxes.find(
const sandboxes = detail.sandboxes as Array<(typeof detail.sandboxes)[number] & { sandboxActorId?: string }>;
const sandbox = sandboxes.find(
(sb) =>
sb.sandboxId === sandboxId &&
sb.sandboxProviderId === sandboxProviderId &&
typeof (sb as any).sandboxActorId === "string" &&
(sb as any).sandboxActorId.length > 0,
) as { sandboxActorId?: string } | undefined;
typeof sb.sandboxActorId === "string" &&
sb.sandboxActorId.length > 0,
);
if (sandbox?.sandboxActorId) {
return (client as any).taskSandbox.getForId(sandbox.sandboxActorId);
}
@ -562,14 +577,28 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}
};
const getTaskDetailWithAuth = async (organizationId: string, repoId: string, taskIdValue: string): Promise<WorkspaceTaskDetail> => {
return (await task(organizationId, repoId, taskIdValue)).getTaskDetail(await getAuthSessionInput());
};
const getSessionDetailWithAuth = async (
organizationId: string,
repoId: string,
taskIdValue: string,
sessionId: string,
): Promise<WorkspaceSessionDetail> => {
return (await task(organizationId, repoId, taskIdValue)).getSessionDetail(await withAuthSessionInput({ sessionId }));
};
const getWorkspaceCompat = async (organizationId: string): Promise<TaskWorkspaceSnapshot> => {
const authSessionInput = await getAuthSessionInput();
const summary = await (await organization(organizationId)).getOrganizationSummary({ organizationId });
const tasks = (
await Promise.all(
summary.taskSummaries.map(async (taskSummary) => {
const resolvedTasks = await Promise.all(
summary.taskSummaries.map(async (taskSummary) => {
let detail;
try {
detail = await (await task(organizationId, taskSummary.repoId, taskSummary.id)).getTaskDetail();
const taskHandle = await task(organizationId, taskSummary.repoId, taskSummary.id);
detail = await taskHandle.getTaskDetail(authSessionInput);
} catch (error) {
if (isActorNotFoundError(error)) {
return null;
@ -579,7 +608,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
const sessionDetails = await Promise.all(
detail.sessionsSummary.map(async (session) => {
try {
const full = await (await task(organizationId, detail.repoId, detail.id)).getSessionDetail({ sessionId: session.id });
const full = await (await task(organizationId, detail.repoId, detail.id)).getSessionDetail({
sessionId: session.id,
...(authSessionInput ?? {}),
});
return [session.id, full] as const;
} catch (error) {
if (isActorNotFoundError(error)) {
@ -599,6 +631,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
updatedAtMs: detail.updatedAtMs,
branch: detail.branch,
pullRequest: detail.pullRequest,
activeSessionId: detail.activeSessionId ?? null,
sessions: detail.sessionsSummary.map((session) => {
const full = sessionDetailsById.get(session.id);
return {
@ -619,10 +652,11 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
diffs: detail.diffs,
fileTree: detail.fileTree,
minutesUsed: detail.minutesUsed,
activeSandboxId: detail.activeSandboxId ?? null,
};
}),
)
).filter((task): task is TaskWorkspaceSnapshot["tasks"][number] => task !== null);
);
const tasks = resolvedTasks.filter((task): task is Exclude<(typeof resolvedTasks)[number], null> => task !== null);
const repositories = summary.repos
.map((repo) => ({
@ -1170,16 +1204,24 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return await withSandboxHandle(organizationId, sandboxProviderId, sandboxId, async (handle) => handle.sandboxAgentConnection());
},
async getSandboxWorkspaceModelGroups(
organizationId: string,
sandboxProviderId: SandboxProviderId,
sandboxId: string,
): Promise<WorkspaceModelGroup[]> {
return await withSandboxHandle(organizationId, sandboxProviderId, sandboxId, async (handle) => handle.listWorkspaceModelGroups());
},
async getOrganizationSummary(organizationId: string): Promise<OrganizationSummarySnapshot> {
return (await organization(organizationId)).getOrganizationSummary({ organizationId });
},
async getTaskDetail(organizationId: string, repoId: string, taskIdValue: string): Promise<WorkspaceTaskDetail> {
return (await task(organizationId, repoId, taskIdValue)).getTaskDetail();
return await getTaskDetailWithAuth(organizationId, repoId, taskIdValue);
},
async getSessionDetail(organizationId: string, repoId: string, taskIdValue: string, sessionId: string): Promise<WorkspaceSessionDetail> {
return (await task(organizationId, repoId, taskIdValue)).getSessionDetail({ sessionId });
return await getSessionDetailWithAuth(organizationId, repoId, taskIdValue, sessionId);
},
async getWorkspace(organizationId: string): Promise<TaskWorkspaceSnapshot> {
@ -1191,73 +1233,69 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
},
async createWorkspaceTask(organizationId: string, input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse> {
return (await organization(organizationId)).createWorkspaceTask(input);
return (await organization(organizationId)).createWorkspaceTask(await withAuthSessionInput(input));
},
async markWorkspaceUnread(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await (await organization(organizationId)).markWorkspaceUnread(input);
await (await organization(organizationId)).markWorkspaceUnread(await withAuthSessionInput(input));
},
async renameWorkspaceTask(organizationId: string, input: TaskWorkspaceRenameInput): Promise<void> {
await (await organization(organizationId)).renameWorkspaceTask(input);
await (await organization(organizationId)).renameWorkspaceTask(await withAuthSessionInput(input));
},
async createWorkspaceSession(organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
return await (await organization(organizationId)).createWorkspaceSession(input);
return await (await organization(organizationId)).createWorkspaceSession(await withAuthSessionInput(input));
},
async renameWorkspaceSession(organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise<void> {
await (await organization(organizationId)).renameWorkspaceSession(input);
await (await organization(organizationId)).renameWorkspaceSession(await withAuthSessionInput(input));
},
async selectWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await (await organization(organizationId)).selectWorkspaceSession(await withAuthSessionInput(input));
},
async setWorkspaceSessionUnread(organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await (await organization(organizationId)).setWorkspaceSessionUnread(input);
await (await organization(organizationId)).setWorkspaceSessionUnread(await withAuthSessionInput(input));
},
async updateWorkspaceDraft(organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
await (await organization(organizationId)).updateWorkspaceDraft(input);
await (await organization(organizationId)).updateWorkspaceDraft(await withAuthSessionInput(input));
},
async changeWorkspaceModel(organizationId: string, input: TaskWorkspaceChangeModelInput): Promise<void> {
await (await organization(organizationId)).changeWorkspaceModel(input);
await (await organization(organizationId)).changeWorkspaceModel(await withAuthSessionInput(input));
},
async sendWorkspaceMessage(organizationId: string, input: TaskWorkspaceSendMessageInput): Promise<void> {
await (await organization(organizationId)).sendWorkspaceMessage(input);
await (await organization(organizationId)).sendWorkspaceMessage(await withAuthSessionInput(input));
},
async stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await (await organization(organizationId)).stopWorkspaceSession(input);
await (await organization(organizationId)).stopWorkspaceSession(await withAuthSessionInput(input));
},
async closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await (await organization(organizationId)).closeWorkspaceSession(input);
await (await organization(organizationId)).closeWorkspaceSession(await withAuthSessionInput(input));
},
async publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await (await organization(organizationId)).publishWorkspacePr(input);
await (await organization(organizationId)).publishWorkspacePr(await withAuthSessionInput(input));
},
async revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void> {
await (await organization(organizationId)).revertWorkspaceFile(input);
await (await organization(organizationId)).revertWorkspaceFile(await withAuthSessionInput(input));
},
async adminReloadGithubOrganization(organizationId: string): Promise<void> {
await (await organization(organizationId)).adminReloadGithubOrganization();
},
async adminReloadGithubPullRequests(organizationId: string): Promise<void> {
await (await organization(organizationId)).adminReloadGithubPullRequests();
},
async adminReloadGithubRepository(organizationId: string, repoId: string): Promise<void> {
await (await organization(organizationId)).adminReloadGithubRepository({ repoId });
},
async adminReloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise<void> {
await (await organization(organizationId)).adminReloadGithubPullRequest({ repoId, prNumber });
},
async health(): Promise<{ ok: true }> {
const organizationId = options.defaultOrganizationId;
if (!organizationId) {

View file

@ -1,4 +1,8 @@
import type { WorkspaceModelId } from "@sandbox-agent/foundry-shared";
import { DEFAULT_WORKSPACE_MODEL_GROUPS, DEFAULT_WORKSPACE_MODEL_ID, type WorkspaceModelId } from "@sandbox-agent/foundry-shared";
const claudeModels = DEFAULT_WORKSPACE_MODEL_GROUPS.find((group) => group.agentKind === "Claude")?.models ?? [];
const CLAUDE_SECONDARY_MODEL_ID = claudeModels[1]?.id ?? claudeModels[0]?.id ?? DEFAULT_WORKSPACE_MODEL_ID;
const CLAUDE_TERTIARY_MODEL_ID = claudeModels[2]?.id ?? CLAUDE_SECONDARY_MODEL_ID;
import { injectMockLatency } from "./mock/latency.js";
import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" };
@ -233,7 +237,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "nathan",
roleLabel: "Founder",
eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"],
defaultModel: "gpt-5.3-codex",
defaultModel: DEFAULT_WORKSPACE_MODEL_ID,
},
{
id: "user-maya",
@ -242,7 +246,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "maya",
roleLabel: "Staff Engineer",
eligibleOrganizationIds: ["acme"],
defaultModel: "claude-sonnet-4",
defaultModel: CLAUDE_SECONDARY_MODEL_ID,
},
{
id: "user-jamie",
@ -251,7 +255,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "jamie",
roleLabel: "Platform Lead",
eligibleOrganizationIds: ["personal-jamie", "rivet"],
defaultModel: "claude-opus-4",
defaultModel: CLAUDE_TERTIARY_MODEL_ID,
},
],
organizations: [

View file

@ -20,6 +20,7 @@ import type {
TaskWorkspaceUpdateDraftInput,
TaskEvent,
WorkspaceSessionDetail,
WorkspaceModelGroup,
WorkspaceTaskDetail,
WorkspaceTaskSummary,
OrganizationEvent,
@ -32,6 +33,7 @@ import type {
StarSandboxAgentRepoResult,
SwitchResult,
} from "@sandbox-agent/foundry-shared";
import { DEFAULT_WORKSPACE_MODEL_GROUPS } from "@sandbox-agent/foundry-shared";
import type { ProcessCreateRequest, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
import type { ActorConn, BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js";
import { getSharedMockWorkspaceClient } from "./workspace-client.js";
@ -173,6 +175,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
updatedAtMs: task.updatedAtMs,
branch: task.branch,
pullRequest: task.pullRequest,
activeSessionId: task.activeSessionId ?? task.sessions[0]?.id ?? null,
sessionsSummary: task.sessions.map((tab) => ({
id: tab.id,
sessionId: tab.sessionId,
@ -190,13 +193,6 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
const buildTaskDetail = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskDetail => ({
...buildTaskSummary(task),
task: task.title,
agentType: task.sessions[0]?.agent === "Codex" ? "codex" : "claude",
runtimeStatus: toTaskStatus(task.status === "archived" ? "archived" : "running", task.status === "archived"),
statusMessage: task.status === "archived" ? "archived" : "mock sandbox ready",
activeSessionId: task.sessions[0]?.sessionId ?? null,
diffStat: task.fileChanges.length > 0 ? `+${task.fileChanges.length}/-${task.fileChanges.length}` : "+0/-0",
prUrl: task.pullRequest ? `https://example.test/pr/${task.pullRequest.number}` : null,
reviewStatus: null,
fileChanges: task.fileChanges,
diffs: task.diffs,
fileTree: task.fileTree,
@ -236,6 +232,20 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
const taskSummaries = snapshot.tasks.map(buildTaskSummary);
return {
organizationId: defaultOrganizationId,
github: {
connectedAccount: "mock",
installationStatus: "connected",
syncStatus: "synced",
importedRepoCount: snapshot.repos.length,
lastSyncLabel: "Synced just now",
lastSyncAt: nowMs(),
lastWebhookAt: null,
lastWebhookEvent: "",
syncGeneration: 1,
syncPhase: null,
processedRepositoryCount: snapshot.repos.length,
totalRepositoryCount: snapshot.repos.length,
},
repos: snapshot.repos.map((repo) => {
const repoTasks = taskSummaries.filter((task) => task.repoId === repo.id);
return {
@ -298,9 +308,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
task: task.title,
sandboxProviderId: "local",
status: toTaskStatus(archived ? "archived" : "running", archived),
statusMessage: archived ? "archived" : "mock sandbox ready",
activeSandboxId: task.id,
activeSessionId: task.sessions[0]?.sessionId ?? null,
sandboxes: [
{
sandboxId: task.id,
@ -312,17 +320,6 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
updatedAt: task.updatedAtMs,
},
],
agentType: task.sessions[0]?.agent === "Codex" ? "codex" : "claude",
prSubmitted: Boolean(task.pullRequest),
diffStat: task.fileChanges.length > 0 ? `+${task.fileChanges.length}/-${task.fileChanges.length}` : "+0/-0",
prUrl: task.pullRequest ? `https://example.test/pr/${task.pullRequest.number}` : null,
prAuthor: task.pullRequest ? "mock" : null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: "0",
hasUnpushed: task.fileChanges.length > 0 ? "1" : "0",
parentBranch: null,
createdAt: task.updatedAtMs,
updatedAt: task.updatedAtMs,
};
@ -636,6 +633,14 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
return { endpoint: "mock://terminal-unavailable" };
},
async getSandboxWorkspaceModelGroups(
_organizationId: string,
_sandboxProviderId: SandboxProviderId,
_sandboxId: string,
): Promise<WorkspaceModelGroup[]> {
return DEFAULT_WORKSPACE_MODEL_GROUPS;
},
async getOrganizationSummary(): Promise<OrganizationSummarySnapshot> {
return buildOrganizationSummary();
},
@ -693,6 +698,13 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
emitSessionUpdate(input.taskId, input.sessionId);
},
async selectWorkspaceSession(_organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await workspace.selectSession(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async setWorkspaceSessionUnread(_organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await workspace.setSessionUnread(input);
emitOrganizationSnapshot();
@ -747,13 +759,8 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
},
async adminReloadGithubOrganization(): Promise<void> {},
async adminReloadGithubPullRequests(): Promise<void> {},
async adminReloadGithubRepository(): Promise<void> {},
async adminReloadGithubPullRequest(): Promise<void> {},
async health(): Promise<{ ok: true }> {
return { ok: true };
},

View file

@ -9,6 +9,7 @@ import {
slugify,
uid,
} from "../workspace-model.js";
import { DEFAULT_WORKSPACE_MODEL_ID, workspaceAgentForModel } from "@sandbox-agent/foundry-shared";
import type {
TaskWorkspaceAddSessionResponse,
TaskWorkspaceChangeModelInput,
@ -74,20 +75,19 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
id,
repoId: repo.id,
title: input.title?.trim() || "New Task",
status: "new",
status: "init_enqueue_provision",
repoName: repo.label,
updatedAtMs: nowMs(),
branch: input.branch?.trim() || null,
pullRequest: null,
activeSessionId: sessionId,
sessions: [
{
id: sessionId,
sessionId: sessionId,
sessionName: "Session 1",
agent: providerAgent(
MODEL_GROUPS.find((group) => group.models.some((model) => model.id === (input.model ?? "claude-sonnet-4")))?.provider ?? "Claude",
),
model: input.model ?? "claude-sonnet-4",
agent: workspaceAgentForModel(input.model ?? DEFAULT_WORKSPACE_MODEL_ID, MODEL_GROUPS),
model: input.model ?? DEFAULT_WORKSPACE_MODEL_ID,
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -140,7 +140,18 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
this.updateTask(input.taskId, (task) => ({
...task,
updatedAtMs: nowMs(),
pullRequest: { number: nextPrNumber, status: "ready" },
pullRequest: {
number: nextPrNumber,
title: task.title,
state: "open",
url: `https://example.test/pr/${nextPrNumber}`,
headRefName: task.branch ?? `task/${task.id}`,
baseRefName: "main",
repoFullName: task.repoName,
authorLogin: "mock",
isDraft: false,
updatedAtMs: nowMs(),
},
}));
}
@ -189,7 +200,7 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
const startedAtMs = nowMs();
this.updateTask(input.taskId, (currentTask) => {
const isFirstOnTask = currentTask.status === "new";
const isFirstOnTask = String(currentTask.status).startsWith("init_");
const newTitle = isFirstOnTask ? (text.length > 50 ? `${text.slice(0, 47)}...` : text) : currentTask.title;
const newBranch = isFirstOnTask ? `feat/${slugify(newTitle)}` : currentTask.branch;
const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)];
@ -303,6 +314,14 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
});
}
async selectSession(input: TaskWorkspaceSessionInput): Promise<void> {
this.assertSession(input.taskId, input.sessionId);
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
activeSessionId: input.sessionId,
}));
}
async setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
@ -329,6 +348,7 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
return {
...currentTask,
activeSessionId: currentTask.activeSessionId === input.sessionId ? (currentTask.sessions.find((candidate) => candidate.id !== input.sessionId)?.id ?? null) : currentTask.activeSessionId,
sessions: currentTask.sessions.filter((candidate) => candidate.id !== input.sessionId),
};
});
@ -342,8 +362,8 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
sessionId: nextSessionId,
sandboxSessionId: null,
sessionName: `Session ${this.requireTask(input.taskId).sessions.length + 1}`,
agent: "Claude",
model: "claude-sonnet-4",
agent: workspaceAgentForModel(DEFAULT_WORKSPACE_MODEL_ID, MODEL_GROUPS),
model: DEFAULT_WORKSPACE_MODEL_ID,
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -355,6 +375,7 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
updatedAtMs: nowMs(),
activeSessionId: nextSession.id,
sessions: [...currentTask.sessions, nextSession],
}));
return { sessionId: nextSession.id };
@ -369,7 +390,7 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
sessions: currentTask.sessions.map((candidate) =>
candidate.id === input.sessionId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate,
candidate.id === input.sessionId ? { ...candidate, model: input.model, agent: workspaceAgentForModel(input.model, MODEL_GROUPS) } : candidate,
),
}));
}

View file

@ -109,6 +109,11 @@ class RemoteWorkspaceStore implements TaskWorkspaceClient {
await this.refresh();
}
async selectSession(input: TaskWorkspaceSessionInput): Promise<void> {
await this.backend.selectWorkspaceSession(this.organizationId, input);
await this.refresh();
}
async setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await this.backend.setWorkspaceSessionUnread(this.organizationId, input);
await this.refresh();

View file

@ -81,6 +81,7 @@ class TopicEntry<TData, TParams, TEvent> {
private unsubscribeError: (() => void) | null = null;
private teardownTimer: ReturnType<typeof setTimeout> | null = null;
private startPromise: Promise<void> | null = null;
private eventPromise: Promise<void> = Promise.resolve();
private started = false;
constructor(
@ -157,12 +158,7 @@ class TopicEntry<TData, TParams, TEvent> {
try {
this.conn = await this.definition.connect(this.backend, this.params);
this.unsubscribeEvent = this.conn.on(this.definition.event, (event: TEvent) => {
if (this.data === undefined) {
return;
}
this.data = this.definition.applyEvent(this.data, event);
this.lastRefreshAt = Date.now();
this.notify();
void this.applyEvent(event);
});
this.unsubscribeError = this.conn.onError((error: unknown) => {
this.status = "error";
@ -182,6 +178,33 @@ class TopicEntry<TData, TParams, TEvent> {
}
}
private applyEvent(event: TEvent): Promise<void> {
this.eventPromise = this.eventPromise
.then(async () => {
if (!this.started || this.data === undefined) {
return;
}
const nextData = await this.definition.applyEvent(this.backend, this.params, this.data, event);
if (!this.started) {
return;
}
this.data = nextData;
this.status = "connected";
this.error = null;
this.lastRefreshAt = Date.now();
this.notify();
})
.catch((error) => {
this.status = "error";
this.error = error instanceof Error ? error : new Error(String(error));
this.notify();
});
return this.eventPromise;
}
private notify(): void {
for (const listener of [...this.listeners]) {
listener();

View file

@ -16,15 +16,15 @@ import type { ActorConn, BackendClient, SandboxProcessRecord } from "../backend-
* Topic definitions for the subscription manager.
*
* Each topic describes one actor connection plus one materialized read model.
* Events always carry full replacement payloads for the changed entity so the
* client can replace cached state directly instead of reconstructing patches.
* Some topics can apply broadcast payloads directly, while others refetch
* through BackendClient so auth-scoped state stays user-specific.
*/
export interface TopicDefinition<TData, TParams, TEvent> {
key: (params: TParams) => string;
event: string;
connect: (backend: BackendClient, params: TParams) => Promise<ActorConn>;
fetchInitial: (backend: BackendClient, params: TParams) => Promise<TData>;
applyEvent: (current: TData, event: TEvent) => TData;
applyEvent: (backend: BackendClient, params: TParams, current: TData, event: TEvent) => Promise<TData> | TData;
}
export interface AppTopicParams {}
@ -54,7 +54,7 @@ export const topicDefinitions = {
event: "appUpdated",
connect: (backend: BackendClient, _params: AppTopicParams) => backend.connectOrganization("app"),
fetchInitial: (backend: BackendClient, _params: AppTopicParams) => backend.getAppSnapshot(),
applyEvent: (_current: FoundryAppSnapshot, event: AppEvent) => event.snapshot,
applyEvent: (_backend: BackendClient, _params: AppTopicParams, _current: FoundryAppSnapshot, event: AppEvent) => event.snapshot,
} satisfies TopicDefinition<FoundryAppSnapshot, AppTopicParams, AppEvent>,
organization: {
@ -62,7 +62,8 @@ export const topicDefinitions = {
event: "organizationUpdated",
connect: (backend: BackendClient, params: OrganizationTopicParams) => backend.connectOrganization(params.organizationId),
fetchInitial: (backend: BackendClient, params: OrganizationTopicParams) => backend.getOrganizationSummary(params.organizationId),
applyEvent: (_current: OrganizationSummarySnapshot, event: OrganizationEvent) => event.snapshot,
applyEvent: (_backend: BackendClient, _params: OrganizationTopicParams, _current: OrganizationSummarySnapshot, event: OrganizationEvent) =>
event.snapshot,
} satisfies TopicDefinition<OrganizationSummarySnapshot, OrganizationTopicParams, OrganizationEvent>,
task: {
@ -70,7 +71,8 @@ export const topicDefinitions = {
event: "taskUpdated",
connect: (backend: BackendClient, params: TaskTopicParams) => backend.connectTask(params.organizationId, params.repoId, params.taskId),
fetchInitial: (backend: BackendClient, params: TaskTopicParams) => backend.getTaskDetail(params.organizationId, params.repoId, params.taskId),
applyEvent: (_current: WorkspaceTaskDetail, event: TaskEvent) => event.detail,
applyEvent: (backend: BackendClient, params: TaskTopicParams, _current: WorkspaceTaskDetail, _event: TaskEvent) =>
backend.getTaskDetail(params.organizationId, params.repoId, params.taskId),
} satisfies TopicDefinition<WorkspaceTaskDetail, TaskTopicParams, TaskEvent>,
session: {
@ -79,11 +81,11 @@ export const topicDefinitions = {
connect: (backend: BackendClient, params: SessionTopicParams) => backend.connectTask(params.organizationId, params.repoId, params.taskId),
fetchInitial: (backend: BackendClient, params: SessionTopicParams) =>
backend.getSessionDetail(params.organizationId, params.repoId, params.taskId, params.sessionId),
applyEvent: (current: WorkspaceSessionDetail, event: SessionEvent) => {
if (event.session.sessionId !== current.sessionId) {
applyEvent: async (backend: BackendClient, params: SessionTopicParams, current: WorkspaceSessionDetail, event: SessionEvent) => {
if (event.session.sessionId !== params.sessionId) {
return current;
}
return event.session;
return await backend.getSessionDetail(params.organizationId, params.repoId, params.taskId, params.sessionId);
},
} satisfies TopicDefinition<WorkspaceSessionDetail, SessionTopicParams, SessionEvent>,
@ -94,7 +96,8 @@ export const topicDefinitions = {
backend.connectSandbox(params.organizationId, params.sandboxProviderId, params.sandboxId),
fetchInitial: async (backend: BackendClient, params: SandboxProcessesTopicParams) =>
(await backend.listSandboxProcesses(params.organizationId, params.sandboxProviderId, params.sandboxId)).processes,
applyEvent: (_current: SandboxProcessRecord[], event: SandboxProcessesEvent) => event.processes,
applyEvent: (_backend: BackendClient, _params: SandboxProcessesTopicParams, _current: SandboxProcessRecord[], event: SandboxProcessesEvent) =>
event.processes,
} satisfies TopicDefinition<SandboxProcessRecord[], SandboxProcessesTopicParams, SandboxProcessesEvent>,
} as const;

View file

@ -65,7 +65,7 @@ export function filterTasks(rows: TaskRecord[], query: string): TaskRecord[] {
}
return rows.filter((row) => {
const fields = [row.branchName ?? "", row.title ?? "", row.taskId, row.task, row.prAuthor ?? "", row.reviewer ?? ""];
const fields = [row.branchName ?? "", row.title ?? "", row.taskId, row.task];
return fields.some((field) => fuzzyMatch(field, q));
});
}

View file

@ -37,6 +37,7 @@ export interface TaskWorkspaceClient {
updateDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void>;
sendMessage(input: TaskWorkspaceSendMessageInput): Promise<void>;
stopAgent(input: TaskWorkspaceSessionInput): Promise<void>;
selectSession(input: TaskWorkspaceSessionInput): Promise<void>;
setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
renameSession(input: TaskWorkspaceRenameSessionInput): Promise<void>;
closeSession(input: TaskWorkspaceSessionInput): Promise<void>;

View file

@ -1,3 +1,9 @@
import {
DEFAULT_WORKSPACE_MODEL_ID,
DEFAULT_WORKSPACE_MODEL_GROUPS as SharedModelGroups,
workspaceModelLabel as sharedWorkspaceModelLabel,
workspaceProviderAgent as sharedWorkspaceProviderAgent,
} from "@sandbox-agent/foundry-shared";
import type {
WorkspaceAgentKind as AgentKind,
WorkspaceSession as AgentSession,
@ -15,26 +21,8 @@ import type {
} from "@sandbox-agent/foundry-shared";
import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" };
export const MODEL_GROUPS: ModelGroup[] = [
{
provider: "Claude",
models: [
{ id: "claude-sonnet-4", label: "Sonnet 4" },
{ id: "claude-opus-4", label: "Opus 4" },
],
},
{
provider: "OpenAI",
models: [
{ id: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
{ id: "gpt-5.4", label: "GPT-5.4" },
{ id: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ id: "gpt-5.2", label: "GPT-5.2" },
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
],
},
];
export const MODEL_GROUPS: ModelGroup[] = SharedModelGroups;
export const DEFAULT_MODEL_ID: ModelId = DEFAULT_WORKSPACE_MODEL_ID;
const MOCK_REPLIES = [
"Got it. I'll work on that now. Let me start by examining the relevant files...",
@ -73,15 +61,11 @@ export function formatMessageDuration(durationMs: number): string {
}
export function modelLabel(id: ModelId): string {
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((model) => model.id === id));
const model = group?.models.find((candidate) => candidate.id === id);
return model && group ? `${group.provider} ${model.label}` : id;
return sharedWorkspaceModelLabel(id, MODEL_GROUPS);
}
export function providerAgent(provider: string): AgentKind {
if (provider === "Claude") return "Claude";
if (provider === "OpenAI") return "Codex";
return "Cursor";
return sharedWorkspaceProviderAgent(provider);
}
export function slugify(text: string): string {
@ -204,6 +188,28 @@ export function buildHistoryEvents(sessions: AgentSession[]): HistoryEvent[] {
.sort((left, right) => messageOrder(left.messageId) - messageOrder(right.messageId));
}
function buildPullRequestSummary(params: {
number: number;
title: string;
branch: string;
repoName: string;
updatedAtMs: number;
status: "ready" | "draft";
}) {
return {
number: params.number,
title: params.title,
state: "open",
url: `https://github.com/${params.repoName}/pull/${params.number}`,
headRefName: params.branch,
baseRefName: "main",
repoFullName: params.repoName,
authorLogin: "mock",
isDraft: params.status === "draft",
updatedAtMs: params.updatedAtMs,
};
}
function transcriptFromLegacyMessages(sessionId: string, messages: LegacyMessage[]): TranscriptEvent[] {
return messages.map((message, index) => ({
id: message.id,
@ -315,14 +321,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(8),
branch: "NathanFlurry/pi-bootstrap-fix",
pullRequest: { number: 227, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 227,
title: "Normalize Pi ACP bootstrap payloads",
branch: "NathanFlurry/pi-bootstrap-fix",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(8),
status: "ready",
}),
sessions: [
{
id: "t1",
sessionId: "t1",
sessionName: "Pi payload fix",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -484,14 +497,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(3),
branch: "feat/builtin-agent-skills",
pullRequest: { number: 223, status: "draft" },
pullRequest: buildPullRequestSummary({
number: 223,
title: "Auto-inject builtin agent skills at startup",
branch: "feat/builtin-agent-skills",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(3),
status: "draft",
}),
sessions: [
{
id: "t3",
sessionId: "t3",
sessionName: "Skills injection",
agent: "Claude",
model: "claude-opus-4",
model: "opus",
status: "running",
thinkingSinceMs: NOW_MS - 45_000,
unread: false,
@ -584,14 +604,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(45),
branch: "hooks-example",
pullRequest: { number: 225, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 225,
title: "Add hooks example for Claude, Codex, and OpenCode",
branch: "hooks-example",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(45),
status: "ready",
}),
sessions: [
{
id: "t4",
sessionId: "t4",
sessionName: "Example docs",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -659,14 +686,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/rivet",
updatedAtMs: minutesAgo(15),
branch: "actor-reschedule-endpoint",
pullRequest: { number: 4400, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 4400,
title: "Add actor reschedule endpoint",
branch: "actor-reschedule-endpoint",
repoName: "rivet-dev/rivet",
updatedAtMs: minutesAgo(15),
status: "ready",
}),
sessions: [
{
id: "t5",
sessionId: "t5",
sessionName: "Reschedule API",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -793,14 +827,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/rivet",
updatedAtMs: minutesAgo(35),
branch: "feat/dynamic-actors",
pullRequest: { number: 4395, status: "draft" },
pullRequest: buildPullRequestSummary({
number: 4395,
title: "Dynamic actors",
branch: "feat/dynamic-actors",
repoName: "rivet-dev/rivet",
updatedAtMs: minutesAgo(35),
status: "draft",
}),
sessions: [
{
id: "t6",
sessionId: "t6",
sessionName: "Dynamic actors impl",
agent: "Claude",
model: "claude-opus-4",
model: "opus",
status: "idle",
thinkingSinceMs: null,
unread: true,
@ -850,14 +891,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/vbare",
updatedAtMs: minutesAgo(25),
branch: "fix-use-full-cloud-run-pool-name",
pullRequest: { number: 235, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 235,
title: "Use full cloud run pool name for routing",
branch: "fix-use-full-cloud-run-pool-name",
repoName: "rivet-dev/vbare",
updatedAtMs: minutesAgo(25),
status: "ready",
}),
sessions: [
{
id: "t7",
sessionId: "t7",
sessionName: "Pool routing fix",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -959,14 +1007,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/skills",
updatedAtMs: minutesAgo(50),
branch: "fix-guard-support-https-targets",
pullRequest: { number: 125, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 125,
title: "Route compute gateway path correctly",
branch: "fix-guard-support-https-targets",
repoName: "rivet-dev/skills",
updatedAtMs: minutesAgo(50),
status: "ready",
}),
sessions: [
{
id: "t8",
sessionId: "t8",
sessionName: "Guard routing",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -1073,14 +1128,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/skills",
updatedAtMs: minutesAgo(2 * 24 * 60),
branch: "chore-move-compute-gateway-to",
pullRequest: { number: 123, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 123,
title: "Move compute gateway to guard",
branch: "chore-move-compute-gateway-to",
repoName: "rivet-dev/skills",
updatedAtMs: minutesAgo(2 * 24 * 60),
status: "ready",
}),
sessions: [
{
id: "t9",
sessionId: "t9",
sessionName: "Gateway migration",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -1166,8 +1228,6 @@ export function buildInitialTasks(): Task[] {
repoId: "sandbox-agent",
title: "Fix broken auth middleware (error demo)",
status: "error",
runtimeStatus: "error",
statusMessage: "session:error",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(2),
branch: "fix/auth-middleware",
@ -1178,7 +1238,7 @@ export function buildInitialTasks(): Task[] {
sessionId: "status-error-session",
sessionName: "Auth fix",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "error",
thinkingSinceMs: null,
unread: false,
@ -1197,9 +1257,7 @@ export function buildInitialTasks(): Task[] {
id: "status-provisioning",
repoId: "sandbox-agent",
title: "Add rate limiting to API gateway (provisioning demo)",
status: "new",
runtimeStatus: "init_enqueue_provision",
statusMessage: "Queueing sandbox provisioning.",
status: "init_enqueue_provision",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(0),
branch: null,
@ -1211,7 +1269,7 @@ export function buildInitialTasks(): Task[] {
sandboxSessionId: null,
sessionName: "Session 1",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "pending_provision",
thinkingSinceMs: null,
unread: false,
@ -1259,7 +1317,6 @@ export function buildInitialTasks(): Task[] {
repoId: "sandbox-agent",
title: "Refactor WebSocket handler (running demo)",
status: "running",
runtimeStatus: "running",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(1),
branch: "refactor/ws-handler",
@ -1313,45 +1370,9 @@ function repoIdFromFullName(fullName: string): string {
return parts[parts.length - 1] ?? fullName;
}
/**
* Build task entries from open PR fixture data.
* Maps to the backend's PR sync behavior (RepositoryPrSyncActor) where PRs
* appear as first-class sidebar items even without an associated task.
* Each open PR gets a lightweight task entry so it shows in the sidebar.
*/
function buildPrTasks(): Task[] {
// Collect branch names already claimed by hand-written tasks so we don't duplicate
const existingBranches = new Set(
buildInitialTasks()
.map((t) => t.branch)
.filter(Boolean),
);
return rivetDevFixture.openPullRequests
.filter((pr) => !existingBranches.has(pr.headRefName))
.map((pr) => {
const repoId = repoIdFromFullName(pr.repoFullName);
return {
id: `pr-${repoId}-${pr.number}`,
repoId,
title: pr.title,
status: "idle" as const,
repoName: pr.repoFullName,
updatedAtMs: new Date(pr.updatedAt).getTime(),
branch: pr.headRefName,
pullRequest: { number: pr.number, status: pr.draft ? ("draft" as const) : ("ready" as const) },
sessions: [],
fileChanges: [],
diffs: {},
fileTree: [],
minutesUsed: 0,
};
});
}
export function buildInitialMockLayoutViewModel(): TaskWorkspaceSnapshot {
const repos = buildMockRepos();
const tasks = [...buildInitialTasks(), ...buildPrTasks()];
const tasks = buildInitialTasks();
return {
organizationId: "default",
repos,

View file

@ -80,9 +80,10 @@ function parseHistoryPayload(event: HistoryEvent): Record<string, unknown> {
}
}
async function debugDump(client: ReturnType<typeof createBackendClient>, organizationId: string, taskId: string): Promise<string> {
async function debugDump(client: ReturnType<typeof createBackendClient>, organizationId: string, repoId: string, taskId: string): Promise<string> {
try {
const task = await client.getTask(organizationId, taskId);
const task = await client.getTask(organizationId, repoId, taskId);
const detail = await client.getTaskDetail(organizationId, repoId, taskId).catch(() => null);
const history = await client.listHistory({ organizationId, taskId, limit: 80 }).catch(() => []);
const historySummary = history
.slice(0, 20)
@ -90,10 +91,11 @@ async function debugDump(client: ReturnType<typeof createBackendClient>, organiz
.join("\n");
let sessionEventsSummary = "";
if (task.activeSandboxId && task.activeSessionId) {
const activeSessionId = detail?.activeSessionId ?? null;
if (task.activeSandboxId && activeSessionId) {
const events = await client
.listSandboxSessionEvents(organizationId, task.sandboxProviderId, task.activeSandboxId, {
sessionId: task.activeSessionId,
sessionId: activeSessionId,
limit: 50,
})
.then((r) => r.items)
@ -109,13 +111,11 @@ async function debugDump(client: ReturnType<typeof createBackendClient>, organiz
JSON.stringify(
{
status: task.status,
statusMessage: task.statusMessage,
title: task.title,
branchName: task.branchName,
activeSandboxId: task.activeSandboxId,
activeSessionId: task.activeSessionId,
prUrl: task.prUrl,
prSubmitted: task.prSubmitted,
activeSessionId,
pullRequestUrl: detail?.pullRequest?.url ?? null,
},
null,
2,
@ -189,7 +189,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
// Cold local sandbox startup can exceed a few minutes on first run.
8 * 60_000,
1_000,
async () => client.getTask(organizationId, created.taskId),
async () => client.getTask(organizationId, repo.repoId, created.taskId),
(h) => Boolean(h.title && h.branchName && h.activeSandboxId),
(h) => {
if (h.status !== lastStatus) {
@ -200,18 +200,18 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
}
},
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
branchName = namedAndProvisioned.branchName!;
sandboxId = namedAndProvisioned.activeSandboxId!;
const withSession = await poll<TaskRecord>(
const withSession = await poll<Awaited<ReturnType<typeof client.getTaskDetail>>>(
"task to create active session",
3 * 60_000,
1_500,
async () => client.getTask(organizationId, created.taskId),
async () => client.getTaskDetail(organizationId, repo.repoId, created.taskId),
(h) => Boolean(h.activeSessionId),
(h) => {
if (h.status === "error") {
@ -219,7 +219,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
}
},
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -231,14 +231,14 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
2_000,
async () =>
(
await client.listSandboxSessionEvents(organizationId, withSession.sandboxProviderId, sandboxId!, {
await client.listSandboxSessionEvents(organizationId, namedAndProvisioned.sandboxProviderId, sandboxId!, {
sessionId: sessionId!,
limit: 40,
})
).items,
(events) => events.length > 0,
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -246,7 +246,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
"task to reach idle state",
8 * 60_000,
2_000,
async () => client.getTask(organizationId, created.taskId),
async () => client.getTask(organizationId, repo.repoId, created.taskId),
(h) => h.status === "idle",
(h) => {
if (h.status === "error") {
@ -254,7 +254,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
}
},
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -266,7 +266,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
(events) => events.some((e) => e.kind === "task.pr_created"),
)
.catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
})
.then((events) => events.find((e) => e.kind === "task.pr_created")!);
@ -287,16 +287,16 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
expect(prFiles.some((f) => f.filename === expectedFile)).toBe(true);
// Close the task and assert the sandbox is released (stopped).
await client.runAction(organizationId, created.taskId, "archive");
await client.runAction(organizationId, repo.repoId, created.taskId, "archive");
await poll<TaskRecord>(
await poll<Awaited<ReturnType<typeof client.getTaskDetail>>>(
"task to become archived (session released)",
60_000,
1_000,
async () => client.getTask(organizationId, created.taskId),
async () => client.getTaskDetail(organizationId, repo.repoId, created.taskId),
(h) => h.status === "archived" && h.activeSessionId === null,
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -311,7 +311,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
return st.includes("destroyed") || st.includes("stopped") || st.includes("suspended") || st.includes("paused");
},
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
const state = await client.sandboxProviderState(organizationId, "local", sandboxId!).catch(() => null);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n` + `sandbox state: ${state ? state.state : "unknown"}\n` + `${dump}`);
});

View file

@ -15,19 +15,7 @@ function requiredEnv(name: string): string {
function workspaceModelEnv(name: string, fallback: WorkspaceModelId): WorkspaceModelId {
const value = process.env[name]?.trim();
switch (value) {
case "claude-sonnet-4":
case "claude-opus-4":
case "gpt-5.3-codex":
case "gpt-5.4":
case "gpt-5.2-codex":
case "gpt-5.1-codex-max":
case "gpt-5.2":
case "gpt-5.1-codex-mini":
return value;
default:
return fallback;
}
return value && value.length > 0 ? value : fallback;
}
async function sleep(ms: number): Promise<void> {

View file

@ -28,19 +28,7 @@ function requiredEnv(name: string): string {
function workspaceModelEnv(name: string, fallback: WorkspaceModelId): WorkspaceModelId {
const value = process.env[name]?.trim();
switch (value) {
case "claude-sonnet-4":
case "claude-opus-4":
case "gpt-5.3-codex":
case "gpt-5.4":
case "gpt-5.2-codex":
case "gpt-5.1-codex-max":
case "gpt-5.2":
case "gpt-5.1-codex-mini":
return value;
default:
return fallback;
}
return value && value.length > 0 ? value : fallback;
}
function intEnv(name: string, fallback: number): number {

View file

@ -50,6 +50,20 @@ class FakeActorConn implements ActorConn {
function organizationSnapshot(): OrganizationSummarySnapshot {
return {
organizationId: "org-1",
github: {
connectedAccount: "octocat",
installationStatus: "connected",
syncStatus: "synced",
importedRepoCount: 1,
lastSyncLabel: "Synced just now",
lastSyncAt: 10,
lastWebhookAt: null,
lastWebhookEvent: "",
syncGeneration: 1,
syncPhase: null,
processedRepositoryCount: 1,
totalRepositoryCount: 1,
},
repos: [{ id: "repo-1", label: "repo-1", taskCount: 1, latestActivityMs: 10 }],
taskSummaries: [
{
@ -61,10 +75,10 @@ function organizationSnapshot(): OrganizationSummarySnapshot {
updatedAtMs: 10,
branch: "main",
pullRequest: null,
activeSessionId: null,
sessionsSummary: [],
},
],
openPullRequests: [],
};
}
@ -118,6 +132,20 @@ describe("RemoteSubscriptionManager", () => {
type: "organizationUpdated",
snapshot: {
organizationId: "org-1",
github: {
connectedAccount: "octocat",
installationStatus: "connected",
syncStatus: "syncing",
importedRepoCount: 1,
lastSyncLabel: "Syncing repositories...",
lastSyncAt: 10,
lastWebhookAt: null,
lastWebhookEvent: "",
syncGeneration: 2,
syncPhase: "syncing_branches",
processedRepositoryCount: 1,
totalRepositoryCount: 3,
},
repos: [],
taskSummaries: [
{
@ -129,10 +157,10 @@ describe("RemoteSubscriptionManager", () => {
updatedAtMs: 20,
branch: "feature/live",
pullRequest: null,
activeSessionId: null,
sessionsSummary: [],
},
],
openPullRequests: [],
},
} satisfies OrganizationEvent);

View file

@ -12,9 +12,8 @@ const sample: TaskRecord = {
task: "Do test",
sandboxProviderId: "local",
status: "running",
statusMessage: null,
activeSandboxId: "sandbox-1",
activeSessionId: "session-1",
pullRequest: null,
sandboxes: [
{
sandboxId: "sandbox-1",
@ -26,17 +25,6 @@ const sample: TaskRecord = {
updatedAt: 1,
},
],
agentType: null,
prSubmitted: false,
diffStat: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: null,
hasUnpushed: null,
parentBranch: null,
createdAt: 1,
updatedAt: 1,
};

View file

@ -6,7 +6,6 @@ import { subscriptionManager } from "../lib/subscription";
import type {
FoundryAppSnapshot,
FoundryOrganization,
TaskStatus,
TaskWorkspaceSnapshot,
WorkspaceSandboxSummary,
WorkspaceSessionSummary,
@ -28,8 +27,6 @@ export interface DevPanelFocusedTask {
repoId: string;
title: string | null;
status: WorkspaceTaskStatus;
runtimeStatus?: TaskStatus | null;
statusMessage?: string | null;
branch?: string | null;
activeSandboxId?: string | null;
activeSessionId?: string | null;
@ -80,7 +77,7 @@ function timeAgo(ts: number | null): string {
}
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
if (status === "new" || status.startsWith("init_") || status.startsWith("archive_") || status.startsWith("kill_") || status.startsWith("pending_")) {
if (status.startsWith("init_") || status.startsWith("archive_") || status.startsWith("kill_") || status.startsWith("pending_")) {
return t.statusWarning;
}
switch (status) {
@ -159,14 +156,16 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
}, [now]);
const appState = useSubscription(subscriptionManager, "app", {});
const organizationState = useSubscription(subscriptionManager, "organization", { organizationId });
const appSnapshot: FoundryAppSnapshot | null = appState.data ?? null;
const liveGithub = organizationState.data?.github ?? organization?.github ?? null;
const repos = snapshot.repos ?? [];
const tasks = snapshot.tasks ?? [];
const prCount = tasks.filter((task) => task.pullRequest != null).length;
const focusedTaskStatus = focusedTask?.runtimeStatus ?? focusedTask?.status ?? null;
const focusedTaskState = describeTaskState(focusedTaskStatus, focusedTask?.statusMessage ?? null);
const lastWebhookAt = organization?.github.lastWebhookAt ?? null;
const focusedTaskStatus = focusedTask?.status ?? null;
const focusedTaskState = describeTaskState(focusedTaskStatus);
const lastWebhookAt = liveGithub?.lastWebhookAt ?? null;
const hasRecentWebhook = lastWebhookAt != null && now - lastWebhookAt < 5 * 60_000;
const totalOrgs = appSnapshot?.organizations.length ?? 0;
const authStatus = appSnapshot?.auth.status ?? "unknown";
@ -442,7 +441,7 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
{/* GitHub */}
<Section label="GitHub" t={t} css={css}>
{organization ? (
{liveGithub ? (
<div className={css({ display: "flex", flexDirection: "column", gap: "3px", fontSize: "10px" })}>
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
<span
@ -450,13 +449,13 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
width: "5px",
height: "5px",
borderRadius: "50%",
backgroundColor: installStatusColor(organization.github.installationStatus, t),
backgroundColor: installStatusColor(liveGithub.installationStatus, t),
flexShrink: 0,
})}
/>
<span className={css({ color: t.textPrimary, flex: 1 })}>App Install</span>
<span className={`${mono} ${css({ color: installStatusColor(organization.github.installationStatus, t) })}`}>
{organization.github.installationStatus.replace(/_/g, " ")}
<span className={`${mono} ${css({ color: installStatusColor(liveGithub.installationStatus, t) })}`}>
{liveGithub.installationStatus.replace(/_/g, " ")}
</span>
</div>
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
@ -465,14 +464,14 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
width: "5px",
height: "5px",
borderRadius: "50%",
backgroundColor: syncStatusColor(organization.github.syncStatus, t),
backgroundColor: syncStatusColor(liveGithub.syncStatus, t),
flexShrink: 0,
})}
/>
<span className={css({ color: t.textPrimary, flex: 1 })}>Sync</span>
<span className={`${mono} ${css({ color: syncStatusColor(organization.github.syncStatus, t) })}`}>{organization.github.syncStatus}</span>
{organization.github.lastSyncAt != null && (
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(organization.github.lastSyncAt)}</span>
<span className={`${mono} ${css({ color: syncStatusColor(liveGithub.syncStatus, t) })}`}>{liveGithub.syncStatus}</span>
{liveGithub.lastSyncAt != null && (
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(liveGithub.lastSyncAt)}</span>
)}
</div>
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
@ -488,21 +487,27 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
<span className={css({ color: t.textPrimary, flex: 1 })}>Webhook</span>
{lastWebhookAt != null ? (
<span className={`${mono} ${css({ color: hasRecentWebhook ? t.textPrimary : t.textMuted })}`}>
{organization.github.lastWebhookEvent} · {timeAgo(lastWebhookAt)}
{liveGithub.lastWebhookEvent} · {timeAgo(lastWebhookAt)}
</span>
) : (
<span className={`${mono} ${css({ color: t.statusWarning })}`}>never received</span>
)}
</div>
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
<Stat label="imported" value={organization.github.importedRepoCount} t={t} css={css} />
<Stat label="catalog" value={organization.repoCatalog.length} t={t} css={css} />
<Stat label="imported" value={liveGithub.importedRepoCount} t={t} css={css} />
<Stat label="catalog" value={organization?.repoCatalog.length ?? repos.length} t={t} css={css} />
<Stat label="target" value={liveGithub.totalRepositoryCount} t={t} css={css} />
</div>
{organization.github.connectedAccount && (
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{organization.github.connectedAccount}</div>
{liveGithub.connectedAccount && (
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{liveGithub.connectedAccount}</div>
)}
{organization.github.lastSyncLabel && (
<div className={`${mono} ${css({ color: t.textMuted })}`}>last sync: {organization.github.lastSyncLabel}</div>
{liveGithub.lastSyncLabel && (
<div className={`${mono} ${css({ color: t.textMuted })}`}>last sync: {liveGithub.lastSyncLabel}</div>
)}
{liveGithub.syncPhase && (
<div className={`${mono} ${css({ color: t.textTertiary })}`}>
phase: {liveGithub.syncPhase.replace(/^syncing_/, "").replace(/_/g, " ")} ({liveGithub.processedRepositoryCount}/{liveGithub.totalRepositoryCount})
</div>
)}
</div>
) : (

View file

@ -1,11 +1,14 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useStyletron } from "baseui";
import {
DEFAULT_WORKSPACE_MODEL_GROUPS,
DEFAULT_WORKSPACE_MODEL_ID,
createErrorContext,
type FoundryOrganization,
type TaskWorkspaceSnapshot,
type WorkspaceOpenPrSummary,
type WorkspaceModelGroup,
type WorkspaceSessionSummary,
type WorkspaceTaskDetail,
type WorkspaceTaskSummary,
@ -77,29 +80,35 @@ function sanitizeActiveSessionId(task: Task, sessionId: string | null | undefine
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentSessionId;
}
function githubInstallationWarningTitle(organization: FoundryOrganization): string {
return organization.github.installationStatus === "install_required" ? "GitHub App not installed" : "GitHub App needs reconnection";
type GithubStatusView = Pick<FoundryOrganization["github"], "connectedAccount" | "installationStatus" | "syncStatus" | "importedRepoCount" | "lastSyncLabel"> & {
syncPhase?: string | null;
processedRepositoryCount?: number;
totalRepositoryCount?: number;
};
function githubInstallationWarningTitle(github: GithubStatusView): string {
return github.installationStatus === "install_required" ? "GitHub App not installed" : "GitHub App needs reconnection";
}
function githubInstallationWarningDetail(organization: FoundryOrganization): string {
const statusDetail = organization.github.lastSyncLabel.trim();
function githubInstallationWarningDetail(github: GithubStatusView): string {
const statusDetail = github.lastSyncLabel.trim();
const requirementDetail =
organization.github.installationStatus === "install_required"
github.installationStatus === "install_required"
? "Webhooks are required for Foundry to function. Repo sync and PR updates will not work until the GitHub App is installed for this organization."
: "Webhook delivery is unavailable. Repo sync and PR updates will not work until the GitHub App is reconnected.";
return statusDetail ? `${requirementDetail} ${statusDetail}.` : requirementDetail;
}
function GithubInstallationWarning({
organization,
github,
css,
t,
}: {
organization: FoundryOrganization;
github: GithubStatusView;
css: ReturnType<typeof useStyletron>[0];
t: ReturnType<typeof useFoundryTokens>;
}) {
if (organization.github.installationStatus === "connected") {
if (github.installationStatus === "connected") {
return null;
}
@ -123,8 +132,8 @@ function GithubInstallationWarning({
>
<CircleAlert size={15} color={t.statusError} />
<div className={css({ display: "flex", flexDirection: "column", gap: "3px" })}>
<div className={css({ fontSize: "11px", fontWeight: 600, color: t.textPrimary })}>{githubInstallationWarningTitle(organization)}</div>
<div className={css({ fontSize: "11px", lineHeight: 1.45, color: t.textMuted })}>{githubInstallationWarningDetail(organization)}</div>
<div className={css({ fontSize: "11px", fontWeight: 600, color: t.textPrimary })}>{githubInstallationWarningTitle(github)}</div>
<div className={css({ fontSize: "11px", lineHeight: 1.45, color: t.textMuted })}>{githubInstallationWarningDetail(github)}</div>
</div>
</div>
);
@ -164,13 +173,12 @@ function toTaskModel(
id: summary.id,
repoId: summary.repoId,
title: detail?.title ?? summary.title,
status: detail?.runtimeStatus ?? detail?.status ?? summary.status,
runtimeStatus: detail?.runtimeStatus,
statusMessage: detail?.statusMessage ?? null,
status: detail?.status ?? summary.status,
repoName: detail?.repoName ?? summary.repoName,
updatedAtMs: detail?.updatedAtMs ?? summary.updatedAtMs,
branch: detail?.branch ?? summary.branch,
pullRequest: detail?.pullRequest ?? summary.pullRequest,
activeSessionId: detail?.activeSessionId ?? summary.activeSessionId ?? null,
sessions: sessions.map((session) => toSessionModel(session, sessionCache?.get(session.id))),
fileChanges: detail?.fileChanges ?? [],
diffs: detail?.diffs ?? {},
@ -180,40 +188,6 @@ function toTaskModel(
};
}
const OPEN_PR_TASK_PREFIX = "pr:";
function openPrTaskId(prId: string): string {
return `${OPEN_PR_TASK_PREFIX}${prId}`;
}
function isOpenPrTaskId(taskId: string): boolean {
return taskId.startsWith(OPEN_PR_TASK_PREFIX);
}
function toOpenPrTaskModel(pullRequest: WorkspaceOpenPrSummary): Task {
return {
id: openPrTaskId(pullRequest.prId),
repoId: pullRequest.repoId,
title: pullRequest.title,
status: "new",
runtimeStatus: undefined,
statusMessage: pullRequest.authorLogin ? `@${pullRequest.authorLogin}` : null,
repoName: pullRequest.repoFullName,
updatedAtMs: pullRequest.updatedAtMs,
branch: pullRequest.headRefName,
pullRequest: {
number: pullRequest.number,
status: pullRequest.isDraft ? "draft" : "ready",
},
sessions: [],
fileChanges: [],
diffs: {},
fileTree: [],
minutesUsed: 0,
activeSandboxId: null,
};
}
function sessionStateMessage(tab: Task["sessions"][number] | null | undefined): string | null {
if (!tab) {
return null;
@ -258,15 +232,14 @@ interface WorkspaceActions {
updateDraft(input: { repoId: string; taskId: string; sessionId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
sendMessage(input: { repoId: string; taskId: string; sessionId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
stopAgent(input: { repoId: string; taskId: string; sessionId: string }): Promise<void>;
selectSession(input: { repoId: string; taskId: string; sessionId: string }): Promise<void>;
setSessionUnread(input: { repoId: string; taskId: string; sessionId: string; unread: boolean }): Promise<void>;
renameSession(input: { repoId: string; taskId: string; sessionId: string; title: string }): Promise<void>;
closeSession(input: { repoId: string; taskId: string; sessionId: string }): Promise<void>;
addSession(input: { repoId: string; taskId: string; model?: string }): Promise<{ sessionId: string }>;
changeModel(input: { repoId: string; taskId: string; sessionId: string; model: ModelId }): Promise<void>;
adminReloadGithubOrganization(): Promise<void>;
adminReloadGithubPullRequests(): Promise<void>;
adminReloadGithubRepository(repoId: string): Promise<void>;
adminReloadGithubPullRequest(repoId: string, prNumber: number): Promise<void>;
}
const TranscriptPanel = memo(function TranscriptPanel({
@ -287,6 +260,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
rightSidebarCollapsed,
onToggleRightSidebar,
selectedSessionHydrating = false,
modelGroups,
onNavigateToUsage,
}: {
taskWorkspaceClient: WorkspaceActions;
@ -306,13 +280,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
rightSidebarCollapsed?: boolean;
onToggleRightSidebar?: () => void;
selectedSessionHydrating?: boolean;
modelGroups: WorkspaceModelGroup[];
onNavigateToUsage?: () => void;
}) {
const t = useFoundryTokens();
const appSnapshot = useMockAppSnapshot();
const appClient = useMockAppClient();
const currentUser = activeMockUser(appSnapshot);
const defaultModel = currentUser?.defaultModel ?? "claude-sonnet-4";
const defaultModel = currentUser?.defaultModel ?? DEFAULT_WORKSPACE_MODEL_ID;
const [editingField, setEditingField] = useState<"title" | null>(null);
const [editValue, setEditValue] = useState("");
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
@ -335,9 +310,8 @@ const TranscriptPanel = memo(function TranscriptPanel({
const isTerminal = task.status === "archived";
const historyEvents = useMemo(() => buildHistoryEvents(task.sessions), [task.sessions]);
const activeMessages = useMemo(() => buildDisplayMessages(activeAgentSession), [activeAgentSession]);
const taskRuntimeStatus = task.runtimeStatus ?? task.status;
const taskState = describeTaskState(taskRuntimeStatus, task.statusMessage ?? null);
const taskProvisioning = isProvisioningTaskStatus(taskRuntimeStatus);
const taskState = describeTaskState(task.status);
const taskProvisioning = isProvisioningTaskStatus(task.status);
const taskProvisioningMessage = taskState.detail;
const activeSessionMessage = sessionStateMessage(activeAgentSession);
const showPendingSessionState =
@ -562,6 +536,11 @@ const TranscriptPanel = memo(function TranscriptPanel({
if (!isDiffTab(sessionId)) {
onSetLastAgentSessionId(sessionId);
void taskWorkspaceClient.selectSession({
repoId: task.repoId,
taskId: task.id,
sessionId,
});
const session = task.sessions.find((candidate) => candidate.id === sessionId);
if (session?.unread) {
void taskWorkspaceClient.setSessionUnread({
@ -574,7 +553,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSyncRouteSession(task.id, sessionId);
}
},
[task.id, task.sessions, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession],
[task.id, task.repoId, task.sessions, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession],
);
const setSessionUnread = useCallback(
@ -963,6 +942,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
textareaRef={textareaRef}
placeholder={!promptSession.created ? "Describe your task..." : "Send a message..."}
attachments={attachments}
modelGroups={modelGroups}
defaultModel={defaultModel}
model={promptSession.model}
isRunning={promptSession.status === "running"}
@ -1298,30 +1278,20 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
updateDraft: (input) => backendClient.updateWorkspaceDraft(organizationId, input),
sendMessage: (input) => backendClient.sendWorkspaceMessage(organizationId, input),
stopAgent: (input) => backendClient.stopWorkspaceSession(organizationId, input),
selectSession: (input) => backendClient.selectWorkspaceSession(organizationId, input),
setSessionUnread: (input) => backendClient.setWorkspaceSessionUnread(organizationId, input),
renameSession: (input) => backendClient.renameWorkspaceSession(organizationId, input),
closeSession: (input) => backendClient.closeWorkspaceSession(organizationId, input),
addSession: (input) => backendClient.createWorkspaceSession(organizationId, input),
changeModel: (input) => backendClient.changeWorkspaceModel(organizationId, input),
adminReloadGithubOrganization: () => backendClient.adminReloadGithubOrganization(organizationId),
adminReloadGithubPullRequests: () => backendClient.adminReloadGithubPullRequests(organizationId),
adminReloadGithubRepository: (repoId) => backendClient.adminReloadGithubRepository(organizationId, repoId),
adminReloadGithubPullRequest: (repoId, prNumber) => backendClient.adminReloadGithubPullRequest(organizationId, repoId, prNumber),
}),
[organizationId],
);
const organizationState = useSubscription(subscriptionManager, "organization", { organizationId });
const organizationRepos = organizationState.data?.repos ?? [];
const taskSummaries = organizationState.data?.taskSummaries ?? [];
const openPullRequests = organizationState.data?.openPullRequests ?? [];
const openPullRequestsByTaskId = useMemo(
() => new Map(openPullRequests.map((pullRequest) => [openPrTaskId(pullRequest.prId), pullRequest])),
[openPullRequests],
);
const selectedOpenPullRequest = useMemo(
() => (selectedTaskId ? (openPullRequestsByTaskId.get(selectedTaskId) ?? null) : null),
[openPullRequestsByTaskId, selectedTaskId],
);
const selectedTaskSummary = useMemo(
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
[selectedTaskId, taskSummaries],
@ -1365,6 +1335,20 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
: null,
);
const hasSandbox = Boolean(activeSandbox) && sandboxState.status !== "error";
const modelGroupsQuery = useQuery({
queryKey: ["mock-layout", "workspace-model-groups", organizationId, activeSandbox?.sandboxProviderId ?? "", activeSandbox?.sandboxId ?? ""],
enabled: Boolean(activeSandbox?.sandboxId),
staleTime: 30_000,
refetchOnWindowFocus: false,
queryFn: async () => {
if (!activeSandbox) {
throw new Error("Cannot load workspace model groups without an active sandbox.");
}
return await backendClient.getSandboxWorkspaceModelGroups(organizationId, activeSandbox.sandboxProviderId, activeSandbox.sandboxId);
},
});
const modelGroups = modelGroupsQuery.data && modelGroupsQuery.data.length > 0 ? modelGroupsQuery.data : DEFAULT_WORKSPACE_MODEL_GROUPS;
const tasks = useMemo(() => {
const sessionCache = new Map<string, { draft: Task["sessions"][number]["draft"]; transcript: Task["sessions"][number]["transcript"] }>();
if (selectedTaskSummary && taskState.data) {
@ -1389,12 +1373,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const hydratedTasks = taskSummaries.map((summary) =>
summary.id === selectedTaskSummary?.id ? toTaskModel(summary, taskState.data, sessionCache) : toTaskModel(summary),
);
const openPrTasks = openPullRequests.map((pullRequest) => toOpenPrTaskModel(pullRequest));
return [...hydratedTasks, ...openPrTasks].sort((left, right) => right.updatedAtMs - left.updatedAtMs);
}, [openPullRequests, selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, organizationId]);
return hydratedTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
}, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, organizationId]);
const rawRepositories = useMemo(() => groupRepositories(organizationRepos, tasks), [tasks, organizationRepos]);
const appSnapshot = useMockAppSnapshot();
const currentUser = activeMockUser(appSnapshot);
const activeOrg = activeMockOrganization(appSnapshot);
const liveGithub = organizationState.data?.github ?? activeOrg?.github ?? null;
const navigateToUsage = useCallback(() => {
if (activeOrg) {
void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } as never });
@ -1419,11 +1404,9 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const leftWidthRef = useRef(leftWidth);
const rightWidthRef = useRef(rightWidth);
const autoCreatingSessionForTaskRef = useRef<Set<string>>(new Set());
const resolvingOpenPullRequestsRef = useRef<Set<string>>(new Set());
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
const [rightSidebarOpen, setRightSidebarOpen] = useState(true);
const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false);
const [materializingOpenPrId, setMaterializingOpenPrId] = useState<string | null>(null);
const showDevPanel = useDevPanel();
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -1490,80 +1473,17 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
}, []);
const activeTask = useMemo(() => {
const realTasks = tasks.filter((task) => !isOpenPrTaskId(task.id));
if (selectedOpenPullRequest) {
return null;
}
if (selectedTaskId) {
return realTasks.find((task) => task.id === selectedTaskId) ?? realTasks[0] ?? null;
return tasks.find((task) => task.id === selectedTaskId) ?? tasks[0] ?? null;
}
return realTasks[0] ?? null;
}, [selectedOpenPullRequest, selectedTaskId, tasks]);
const materializeOpenPullRequest = useCallback(
async (pullRequest: WorkspaceOpenPrSummary) => {
if (resolvingOpenPullRequestsRef.current.has(pullRequest.prId)) {
return;
}
resolvingOpenPullRequestsRef.current.add(pullRequest.prId);
setMaterializingOpenPrId(pullRequest.prId);
try {
const { taskId, sessionId } = await taskWorkspaceClient.createTask({
repoId: pullRequest.repoId,
task: `Continue work on GitHub PR #${pullRequest.number}: ${pullRequest.title}`,
model: "gpt-5.3-codex",
title: pullRequest.title,
onBranch: pullRequest.headRefName,
});
await navigate({
to: "/organizations/$organizationId/tasks/$taskId",
params: {
organizationId,
taskId,
},
search: { sessionId: sessionId ?? undefined },
replace: true,
});
} catch (error) {
setMaterializingOpenPrId((current) => (current === pullRequest.prId ? null : current));
resolvingOpenPullRequestsRef.current.delete(pullRequest.prId);
logger.error(
{
prId: pullRequest.prId,
repoId: pullRequest.repoId,
branchName: pullRequest.headRefName,
...createErrorContext(error),
},
"failed_to_materialize_open_pull_request_task",
);
}
},
[navigate, taskWorkspaceClient, organizationId],
);
useEffect(() => {
if (!selectedOpenPullRequest) {
if (materializingOpenPrId) {
resolvingOpenPullRequestsRef.current.delete(materializingOpenPrId);
}
setMaterializingOpenPrId(null);
return;
}
void materializeOpenPullRequest(selectedOpenPullRequest);
}, [materializeOpenPullRequest, materializingOpenPrId, selectedOpenPullRequest]);
return tasks[0] ?? null;
}, [selectedTaskId, tasks]);
useEffect(() => {
if (activeTask) {
return;
}
if (selectedOpenPullRequest || materializingOpenPrId) {
return;
}
const fallbackTaskId = tasks[0]?.id;
if (!fallbackTaskId) {
return;
@ -1580,11 +1500,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
search: { sessionId: fallbackTask?.sessions[0]?.id ?? undefined },
replace: true,
});
}, [activeTask, materializingOpenPrId, navigate, selectedOpenPullRequest, tasks, organizationId]);
}, [activeTask, navigate, tasks, organizationId]);
const openDiffs = activeTask ? sanitizeOpenDiffs(activeTask, openDiffsByTask[activeTask.id]) : [];
const lastAgentSessionId = activeTask ? sanitizeLastAgentSessionId(activeTask, lastAgentSessionIdByTask[activeTask.id]) : null;
const activeSessionId = activeTask ? sanitizeActiveSessionId(activeTask, activeSessionIdByTask[activeTask.id], openDiffs, lastAgentSessionId) : null;
const activeSessionId = activeTask
? sanitizeActiveSessionId(activeTask, activeSessionIdByTask[activeTask.id] ?? activeTask.activeSessionId ?? null, openDiffs, lastAgentSessionId)
: null;
const selectedSessionHydrating = Boolean(
selectedSessionId && activeSessionId === selectedSessionId && sessionState.status === "loading" && !sessionState.data,
);
@ -1697,7 +1619,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const { taskId, sessionId } = await taskWorkspaceClient.createTask({
repoId,
task: options?.task ?? "New task",
model: "gpt-5.3-codex",
model: currentUser?.defaultModel ?? DEFAULT_WORKSPACE_MODEL_ID,
title: options?.title ?? "New task",
...(options?.branch ? { branch: options.branch } : {}),
...(options?.onBranch ? { onBranch: options.onBranch } : {}),
@ -1712,7 +1634,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
});
})();
},
[navigate, selectedNewTaskRepoId, taskWorkspaceClient, organizationId],
[currentUser?.defaultModel, navigate, selectedNewTaskRepoId, taskWorkspaceClient, organizationId],
);
const openDiffTab = useCallback(
@ -1741,14 +1663,6 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const selectTask = useCallback(
(id: string) => {
if (isOpenPrTaskId(id)) {
const pullRequest = openPullRequestsByTaskId.get(id);
if (!pullRequest) {
return;
}
void materializeOpenPullRequest(pullRequest);
return;
}
const task = tasks.find((candidate) => candidate.id === id) ?? null;
void navigate({
to: "/organizations/$organizationId/tasks/$taskId",
@ -1759,7 +1673,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
search: { sessionId: task?.sessions[0]?.id ?? undefined },
});
},
[materializeOpenPullRequest, navigate, openPullRequestsByTaskId, tasks, organizationId],
[navigate, tasks, organizationId],
);
const markTaskUnread = useCallback(
@ -1904,7 +1818,6 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
};
if (!activeTask) {
const isMaterializingSelectedOpenPr = Boolean(selectedOpenPullRequest) || materializingOpenPrId != null;
return (
<>
{dragRegion}
@ -1935,9 +1848,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
@ -1979,7 +1890,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
gap: "12px",
}}
>
{activeOrg?.github.syncStatus === "syncing" || activeOrg?.github.syncStatus === "pending" ? (
{liveGithub?.syncStatus === "syncing" || liveGithub?.syncStatus === "pending" ? (
<>
<div
className={css({
@ -2000,19 +1911,18 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
/>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Syncing with GitHub</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
Importing repos from @{activeOrg.github.connectedAccount || "GitHub"}...
{activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.</>}
{liveGithub.lastSyncLabel || `Importing repos from @${liveGithub.connectedAccount || "GitHub"}...`}
{liveGithub.totalRepositoryCount > 0 && (
<>
{" "}
{liveGithub.syncPhase === "syncing_repositories"
? `${liveGithub.importedRepoCount} of ${liveGithub.totalRepositoryCount} repos imported so far.`
: `${liveGithub.processedRepositoryCount} of ${liveGithub.totalRepositoryCount} repos processed in ${liveGithub.syncPhase?.replace(/^syncing_/, "").replace(/_/g, " ") ?? "sync"}.`}
</>
)}
</p>
</>
) : isMaterializingSelectedOpenPr && selectedOpenPullRequest ? (
<>
<SpinnerDot />
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Creating task from pull request</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
Preparing a task for <strong>{selectedOpenPullRequest.title}</strong> on <strong>{selectedOpenPullRequest.headRefName}</strong>.
</p>
</>
) : activeOrg?.github.syncStatus === "error" ? (
) : liveGithub?.syncStatus === "error" ? (
<>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600, color: t.statusError }}>GitHub sync failed</h2>
<p style={{ margin: 0, opacity: 0.75 }}>There was a problem syncing repos from GitHub. Check the dev panel for details.</p>
@ -2066,7 +1976,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
</div>
</div>
</Shell>
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
{liveGithub && <GithubInstallationWarning github={liveGithub} css={css} t={t} />}
{showDevPanel && (
<DevPanel
organizationId={organizationId}
@ -2109,9 +2019,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
@ -2163,9 +2071,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => {
setLeftSidebarPeeking(false);
setLeftSidebarOpen(true);
@ -2181,6 +2087,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskWorkspaceClient={taskWorkspaceClient}
task={activeTask}
hasSandbox={hasSandbox}
modelGroups={modelGroups}
activeSessionId={activeSessionId}
lastAgentSessionId={lastAgentSessionId}
openDiffs={openDiffs}
@ -2233,7 +2140,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
</div>
</div>
</div>
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
{liveGithub && <GithubInstallationWarning github={liveGithub} css={css} t={t} />}
{showDevPanel && (
<DevPanel
organizationId={organizationId}
@ -2244,11 +2151,9 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
repoId: activeTask.repoId,
title: activeTask.title,
status: activeTask.status,
runtimeStatus: activeTask.runtimeStatus ?? null,
statusMessage: activeTask.statusMessage ?? null,
branch: activeTask.branch ?? null,
activeSandboxId: activeTask.activeSandboxId ?? null,
activeSessionId: selectedSessionId ?? activeTask.sessions[0]?.id ?? null,
activeSessionId: activeTask.activeSessionId ?? selectedSessionId ?? activeTask.sessions[0]?.id ?? null,
sandboxes: [],
sessions:
activeTask.sessions?.map((tab) => ({

View file

@ -2,18 +2,21 @@ import { memo, useState } from "react";
import { useStyletron } from "baseui";
import { StatefulPopover, PLACEMENT } from "baseui/popover";
import { ChevronUp, Star } from "lucide-react";
import { workspaceModelLabel, type WorkspaceModelGroup } from "@sandbox-agent/foundry-shared";
import { useFoundryTokens } from "../../app/theme";
import { AgentIcon } from "./ui";
import { MODEL_GROUPS, modelLabel, providerAgent, type ModelId } from "./view-model";
import { type ModelId } from "./view-model";
const ModelPickerContent = memo(function ModelPickerContent({
groups,
value,
defaultModel,
onChange,
onSetDefault,
close,
}: {
groups: WorkspaceModelGroup[];
value: ModelId;
defaultModel: ModelId;
onChange: (id: ModelId) => void;
@ -26,7 +29,7 @@ const ModelPickerContent = memo(function ModelPickerContent({
return (
<div className={css({ minWidth: "220px", padding: "6px 0" })}>
{MODEL_GROUPS.map((group) => (
{groups.map((group) => (
<div key={group.provider}>
<div
className={css({
@ -44,7 +47,7 @@ const ModelPickerContent = memo(function ModelPickerContent({
const isActive = model.id === value;
const isDefault = model.id === defaultModel;
const isHovered = model.id === hoveredId;
const agent = providerAgent(group.provider);
const agent = group.agentKind;
return (
<div
@ -94,11 +97,13 @@ const ModelPickerContent = memo(function ModelPickerContent({
});
export const ModelPicker = memo(function ModelPicker({
groups,
value,
defaultModel,
onChange,
onSetDefault,
}: {
groups: WorkspaceModelGroup[];
value: ModelId;
defaultModel: ModelId;
onChange: (id: ModelId) => void;
@ -137,7 +142,9 @@ export const ModelPicker = memo(function ModelPicker({
},
},
}}
content={({ close }) => <ModelPickerContent value={value} defaultModel={defaultModel} onChange={onChange} onSetDefault={onSetDefault} close={close} />}
content={({ close }) => (
<ModelPickerContent groups={groups} value={value} defaultModel={defaultModel} onChange={onChange} onSetDefault={onSetDefault} close={close} />
)}
>
<div className={css({ display: "inline-flex" })}>
<button
@ -162,7 +169,7 @@ export const ModelPicker = memo(function ModelPicker({
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
>
{modelLabel(value)}
{workspaceModelLabel(value, groups)}
{(isHovered || isOpen) && <ChevronUp size={11} />}
</button>
</div>

View file

@ -2,6 +2,7 @@ import { memo, type Ref } from "react";
import { useStyletron } from "baseui";
import { ChatComposer, type ChatComposerClassNames } from "@sandbox-agent/react";
import { FileCode, SendHorizonal, Square, X } from "lucide-react";
import { type WorkspaceModelGroup } from "@sandbox-agent/foundry-shared";
import { useFoundryTokens } from "../../app/theme";
import { ModelPicker } from "./model-picker";
@ -13,6 +14,7 @@ export const PromptComposer = memo(function PromptComposer({
textareaRef,
placeholder,
attachments,
modelGroups,
defaultModel,
model,
isRunning,
@ -27,6 +29,7 @@ export const PromptComposer = memo(function PromptComposer({
textareaRef: Ref<HTMLTextAreaElement>;
placeholder: string;
attachments: LineAttachment[];
modelGroups: WorkspaceModelGroup[];
defaultModel: ModelId;
model: ModelId;
isRunning: boolean;
@ -172,7 +175,7 @@ export const PromptComposer = memo(function PromptComposer({
renderSubmitContent={() => (isRunning ? <Square size={16} style={{ display: "block" }} /> : <SendHorizonal size={16} style={{ display: "block" }} />)}
renderFooter={() => (
<div className={css({ padding: "0 10px 8px" })}>
<ModelPicker value={model} defaultModel={defaultModel} onChange={onChangeModel} onSetDefault={onSetDefaultModel} />
<ModelPicker groups={modelGroups} value={model} defaultModel={defaultModel} onChange={onChangeModel} onSetDefault={onSetDefaultModel} />
</div>
)}
/>

View file

@ -125,7 +125,7 @@ export const RightSidebar = memo(function RightSidebar({
});
observer.observe(node);
}, []);
const pullRequestUrl = task.pullRequest != null ? `https://github.com/${task.repoName}/pull/${task.pullRequest.number}` : null;
const pullRequestUrl = task.pullRequest?.url ?? null;
const copyFilePath = useCallback(async (path: string) => {
try {

View file

@ -54,10 +54,6 @@ function repositoryIconColor(label: string): string {
return REPOSITORY_COLORS[Math.abs(hash) % REPOSITORY_COLORS.length]!;
}
function isPullRequestSidebarItem(task: Task): boolean {
return task.id.startsWith("pr:");
}
export const Sidebar = memo(function Sidebar({
repositories,
newTaskRepos,
@ -72,9 +68,7 @@ export const Sidebar = memo(function Sidebar({
taskOrderByRepository,
onReorderTasks,
onReloadOrganization,
onReloadPullRequests,
onReloadRepository,
onReloadPullRequest,
onToggleSidebar,
}: {
repositories: RepositorySection[];
@ -90,9 +84,7 @@ export const Sidebar = memo(function Sidebar({
taskOrderByRepository: Record<string, string[]>;
onReorderTasks: (repositoryId: string, fromIndex: number, toIndex: number) => void;
onReloadOrganization: () => void;
onReloadPullRequests: () => void;
onReloadRepository: (repoId: string) => void;
onReloadPullRequest: (repoId: string, prNumber: number) => void;
onToggleSidebar?: () => void;
}) {
const [css] = useStyletron();
@ -444,16 +436,6 @@ export const Sidebar = memo(function Sidebar({
>
Reload organization
</button>
<button
type="button"
onClick={() => {
setHeaderMenuOpen(false);
onReloadPullRequests();
}}
className={css(menuButtonStyle(false, t))}
>
Reload all PRs
</button>
</div>
) : null}
<div
@ -665,15 +647,12 @@ export const Sidebar = memo(function Sidebar({
if (item.type === "task") {
const { repository, task, taskIndex } = item;
const isActive = task.id === activeId;
const isPullRequestItem = isPullRequestSidebarItem(task);
const isRunning = task.sessions.some((s) => s.status === "running");
const isProvisioning =
!isPullRequestItem &&
((String(task.status).startsWith("init_") && task.status !== "init_complete") ||
task.status === "new" ||
task.sessions.some((s) => s.status === "pending_provision" || s.status === "pending_session_create"));
(String(task.status).startsWith("init_") && task.status !== "init_complete") ||
task.sessions.some((s) => s.status === "pending_provision" || s.status === "pending_session_create");
const hasUnread = task.sessions.some((s) => s.unread);
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
const isDraft = task.pullRequest?.isDraft ?? true;
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
const totalRemoved = task.fileChanges.reduce((sum, file) => sum + file.removed, 0);
const hasDiffs = totalAdded > 0 || totalRemoved > 0;
@ -718,17 +697,11 @@ export const Sidebar = memo(function Sidebar({
<div
onClick={() => onSelect(task.id)}
onContextMenu={(event) => {
if (isPullRequestItem && task.pullRequest) {
contextMenu.open(event, [
{ label: "Reload pull request", onClick: () => onReloadPullRequest(task.repoId, task.pullRequest!.number) },
{ label: "Create task", onClick: () => onSelect(task.id) },
]);
return;
}
contextMenu.open(event, [
const items = [
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
]);
];
contextMenu.open(event, items);
}}
className={css({
padding: "8px 12px",
@ -753,11 +726,7 @@ export const Sidebar = memo(function Sidebar({
flexShrink: 0,
})}
>
{isPullRequestItem ? (
<GitPullRequestDraft size={13} color={isDraft ? t.accent : t.textSecondary} />
) : (
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
)}
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
</div>
<div className={css({ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: "1px" })}>
<LabelSmall
@ -773,18 +742,13 @@ export const Sidebar = memo(function Sidebar({
>
{task.title}
</LabelSmall>
{isPullRequestItem && task.statusMessage ? (
<LabelXSmall color={t.textTertiary} $style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{task.statusMessage}
</LabelXSmall>
) : null}
</div>
{task.pullRequest != null ? (
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>
#{task.pullRequest.number}
</LabelXSmall>
{task.pullRequest.status === "draft" ? <CloudUpload size={11} color={t.accent} /> : null}
{task.pullRequest.isDraft ? <CloudUpload size={11} color={t.accent} /> : null}
</span>
) : (
<GitPullRequestDraft size={11} color={t.textTertiary} />

View file

@ -49,10 +49,9 @@ export const TranscriptHeader = memo(function TranscriptHeader({
const t = useFoundryTokens();
const isDesktop = !!import.meta.env.VITE_DESKTOP;
const needsTrafficLightInset = isDesktop && sidebarCollapsed;
const taskStatus = task.runtimeStatus ?? task.status;
const headerStatus = useMemo(
() => deriveHeaderStatus(taskStatus, task.statusMessage ?? null, activeSession?.status ?? null, activeSession?.errorMessage ?? null, hasSandbox),
[taskStatus, task.statusMessage, activeSession?.status, activeSession?.errorMessage, hasSandbox],
() => deriveHeaderStatus(task.status, activeSession?.status ?? null, activeSession?.errorMessage ?? null, hasSandbox),
[task.status, activeSession?.status, activeSession?.errorMessage, hasSandbox],
);
return (

View file

@ -181,6 +181,8 @@ export const AgentIcon = memo(function AgentIcon({ agent, size = 14 }: { agent:
return <OpenAIIcon size={size} />;
case "Cursor":
return <CursorIcon size={size} />;
default:
return <CursorIcon size={size} />;
}
});

View file

@ -1,3 +1,8 @@
import {
DEFAULT_WORKSPACE_MODEL_GROUPS as SharedModelGroups,
workspaceModelLabel as sharedWorkspaceModelLabel,
workspaceProviderAgent as sharedWorkspaceProviderAgent,
} from "@sandbox-agent/foundry-shared";
import type {
WorkspaceAgentKind as AgentKind,
WorkspaceSession as AgentSession,
@ -17,26 +22,7 @@ import { extractEventText } from "../../features/sessions/model";
export type { RepositorySection };
export const MODEL_GROUPS: ModelGroup[] = [
{
provider: "Claude",
models: [
{ id: "claude-sonnet-4", label: "Sonnet 4" },
{ id: "claude-opus-4", label: "Opus 4" },
],
},
{
provider: "OpenAI",
models: [
{ id: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
{ id: "gpt-5.4", label: "GPT-5.4" },
{ id: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ id: "gpt-5.2", label: "GPT-5.2" },
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
],
},
];
export const MODEL_GROUPS: ModelGroup[] = SharedModelGroups;
export function formatRelativeAge(updatedAtMs: number, nowMs = Date.now()): string {
const deltaSeconds = Math.max(0, Math.floor((nowMs - updatedAtMs) / 1000));
@ -94,15 +80,11 @@ export function formatMessageDuration(durationMs: number): string {
}
export function modelLabel(id: ModelId): string {
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((model) => model.id === id));
const model = group?.models.find((candidate) => candidate.id === id);
return model && group ? `${group.provider} ${model.label}` : id;
return sharedWorkspaceModelLabel(id, MODEL_GROUPS);
}
export function providerAgent(provider: string): AgentKind {
if (provider === "Claude") return "Claude";
if (provider === "OpenAI") return "Codex";
return "Cursor";
return sharedWorkspaceProviderAgent(provider);
}
const DIFF_PREFIX = "diff:";

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentType, RepoBranchRecord, RepoOverview, TaskWorkspaceSnapshot, WorkspaceTaskStatus } from "@sandbox-agent/foundry-shared";
import type { RepoBranchRecord, RepoOverview, TaskWorkspaceSnapshot, WorkspaceTaskStatus } from "@sandbox-agent/foundry-shared";
import { currentFoundryOrganization, useSubscription } from "@sandbox-agent/foundry-client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
@ -14,7 +14,6 @@ import { StyledDivider } from "baseui/divider";
import { styled, useStyletron } from "baseui";
import { HeadingSmall, HeadingXSmall, LabelSmall, LabelXSmall, MonoLabelSmall, ParagraphSmall } from "baseui/typography";
import { Bot, CircleAlert, FolderGit2, GitBranch, MessageSquareText, SendHorizontal } from "lucide-react";
import { formatDiffStat } from "../features/tasks/model";
import { deriveHeaderStatus, describeTaskState } from "../features/tasks/status";
import { HeaderStatusPill } from "./mock-layout/ui";
import { buildTranscript, resolveSessionSelection } from "../features/sessions/model";
@ -95,25 +94,13 @@ const FILTER_OPTIONS: SelectItem[] = [
{ id: "all", label: "All Branches" },
];
const AGENT_OPTIONS: SelectItem[] = [
{ id: "codex", label: "codex" },
{ id: "claude", label: "claude" },
];
function statusKind(status: WorkspaceTaskStatus): StatusTagKind {
if (status === "running") return "positive";
if (status === "error") return "negative";
if (status === "new" || String(status).startsWith("init_")) return "warning";
if (String(status).startsWith("init_")) return "warning";
return "neutral";
}
function normalizeAgent(agent: string | null): AgentType | undefined {
if (agent === "claude" || agent === "codex") {
return agent;
}
return undefined;
}
function formatTime(value: number): string {
return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
@ -160,7 +147,7 @@ function repoSummary(overview: RepoOverview | undefined): {
if (row.taskId) {
mapped += 1;
}
if (row.prNumber && row.prState !== "MERGED" && row.prState !== "CLOSED") {
if (row.pullRequest && row.pullRequest.state !== "MERGED" && row.pullRequest.state !== "CLOSED") {
openPrs += 1;
}
}
@ -174,15 +161,25 @@ function repoSummary(overview: RepoOverview | undefined): {
}
function branchKind(row: RepoBranchRecord): StatusTagKind {
if (row.prState === "OPEN" || row.prState === "DRAFT") {
if (row.pullRequest?.isDraft || row.pullRequest?.state === "OPEN") {
return "warning";
}
if (row.prState === "MERGED") {
if (row.pullRequest?.state === "MERGED") {
return "positive";
}
return "neutral";
}
function branchPullRequestLabel(branch: RepoBranchRecord): string {
if (!branch.pullRequest) {
return "no pr";
}
if (branch.pullRequest.isDraft) {
return "draft";
}
return branch.pullRequest.state.toLowerCase();
}
function matchesOverviewFilter(branch: RepoBranchRecord, filter: RepoOverviewFilter): boolean {
if (filter === "archived") {
return branch.taskStatus === "archived";
@ -332,14 +329,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
const [createTaskOpen, setCreateTaskOpen] = useState(false);
const [selectedOverviewBranch, setSelectedOverviewBranch] = useState<string | null>(null);
const [overviewFilter, setOverviewFilter] = useState<RepoOverviewFilter>("active");
const [newAgentType, setNewAgentType] = useState<AgentType>(() => {
try {
const raw = globalThis.localStorage?.getItem("hf.settings.agentType");
return raw === "claude" || raw === "codex" ? raw : "codex";
} catch {
return "codex";
}
});
const [createError, setCreateError] = useState<string | null>(null);
const appState = useSubscription(subscriptionManager, "app", {});
@ -383,14 +372,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
}
}, [createRepoId, repoOverviewMode, repos, selectedRepoId]);
useEffect(() => {
try {
globalThis.localStorage?.setItem("hf.settings.agentType", newAgentType);
} catch {
// ignore storage failures
}
}, [newAgentType]);
const repoGroups = useMemo(() => {
const byRepo = new Map<string, typeof rows>();
for (const row of rows) {
@ -451,10 +432,10 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
}, [selectedForSession?.id]);
const sessionRows = selectedForSession?.sessionsSummary ?? [];
const taskRuntimeStatus = selectedForSession?.runtimeStatus ?? selectedForSession?.status ?? null;
const taskStatusState = describeTaskState(taskRuntimeStatus, selectedForSession?.statusMessage ?? null);
const taskStatus = selectedForSession?.status ?? null;
const taskStatusState = describeTaskState(taskStatus);
const taskStateSummary = `${taskStatusState.title}. ${taskStatusState.detail}`;
const shouldUseTaskStateEmptyState = Boolean(selectedForSession && taskRuntimeStatus && taskRuntimeStatus !== "running" && taskRuntimeStatus !== "idle");
const shouldUseTaskStateEmptyState = Boolean(selectedForSession && taskStatus && taskStatus !== "running" && taskStatus !== "idle");
const sessionSelection = useMemo(
() =>
resolveSessionSelection({
@ -505,8 +486,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
repoId: task.repoId,
title: task.title,
status: task.status,
runtimeStatus: selectedForSession?.runtimeStatus ?? null,
statusMessage: selectedForSession?.statusMessage ?? null,
branch: task.branch ?? null,
activeSandboxId: selectedForSession?.activeSandboxId ?? null,
activeSessionId: selectedForSession?.activeSessionId ?? null,
@ -524,8 +503,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
repoId: task.repoId,
title: task.title,
status: task.status,
runtimeStatus: selectedForSession?.id === task.id ? selectedForSession.runtimeStatus : undefined,
statusMessage: selectedForSession?.id === task.id ? selectedForSession.statusMessage : null,
repoName: task.repoName,
updatedAtMs: task.updatedAtMs,
branch: task.branch ?? null,
@ -553,13 +530,15 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
if (!selectedForSession || !activeSandbox?.sandboxId) {
throw new Error("No sandbox is available for this task");
}
const preferredAgent =
selectedSessionSummary?.agent === "Claude" ? "claude" : selectedSessionSummary?.agent === "Codex" ? "codex" : undefined;
return backendClient.createSandboxSession({
organizationId,
sandboxProviderId: activeSandbox.sandboxProviderId,
sandboxId: activeSandbox.sandboxId,
prompt: selectedForSession.task,
cwd: activeSandbox.cwd ?? undefined,
agent: normalizeAgent(selectedForSession.agentType),
agent: preferredAgent,
});
};
@ -616,7 +595,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
organizationId,
repoId,
task,
agentType: newAgentType,
explicitTitle: draftTitle || undefined,
explicitBranchName: createOnBranch ? undefined : draftBranchName || undefined,
onBranch: createOnBranch ?? undefined,
@ -656,7 +634,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.id, label: repo.label })), [repos]);
const selectedRepoOption = repoOptions.find((option) => option.id === createRepoId) ?? null;
const selectedAgentOption = useMemo(() => createOption(AGENT_OPTIONS.find((option) => option.id === newAgentType) ?? AGENT_OPTIONS[0]!), [newAgentType]);
const selectedFilterOption = useMemo(
() => createOption(FILTER_OPTIONS.find((option) => option.id === overviewFilter) ?? FILTER_OPTIONS[0]!),
[overviewFilter],
@ -1057,23 +1034,23 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
</div>
<div className={cellClass}>{branch.taskTitle ?? branch.taskId ?? "-"}</div>
<div className={cellClass}>
{branch.prNumber ? (
{branch.pullRequest ? (
<a
href={branch.prUrl ?? undefined}
href={branch.pullRequest.url}
target="_blank"
rel="noreferrer"
className={css({
color: theme.colors.contentPrimary,
})}
>
#{branch.prNumber} {branch.prState ?? "open"}
#{branch.pullRequest.number} {branchPullRequestLabel(branch)}
</a>
) : (
<span className={css({ color: theme.colors.contentSecondary })}>-</span>
)}
</div>
<div className={cellClass}>
{branch.ciStatus ?? "-"} / {branch.reviewStatus ?? "-"}
{branch.ciStatus ?? "-"} / {branch.pullRequest ? (branch.pullRequest.isDraft ? "draft" : "ready") : "-"}
</div>
<div className={cellClass}>{formatRelativeAge(branch.updatedAt)}</div>
<div className={cellClass}>
@ -1098,7 +1075,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
</Button>
) : null}
<StatusPill kind={branchKind(branch)}>{branch.prState?.toLowerCase() ?? "no pr"}</StatusPill>
<StatusPill kind={branchKind(branch)}>{branchPullRequestLabel(branch)}</StatusPill>
</div>
</div>
</div>
@ -1138,7 +1115,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
<HeaderStatusPill
status={deriveHeaderStatus(
taskRuntimeStatus ?? selectedForSession.status,
selectedForSession.statusMessage ?? null,
selectedSessionSummary?.status ?? null,
selectedSessionSummary?.errorMessage ?? null,
Boolean(activeSandbox?.sandboxId),
@ -1266,8 +1242,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
{shouldUseTaskStateEmptyState
? taskStateSummary
: (selectedForSession?.statusMessage ??
(isPendingProvision ? "The task is still provisioning." : "The session is being created."))}
: (isPendingProvision ? "The task is still provisioning." : "The session is being created.")}
</ParagraphSmall>
</div>
) : null}
@ -1277,15 +1252,13 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
{shouldUseTaskStateEmptyState
? taskStateSummary
: isPendingProvision
? (selectedForSession.statusMessage ?? "Provisioning sandbox...")
? "Provisioning sandbox..."
: isPendingSessionCreate
? "Creating session..."
: isSessionError
? (selectedSessionSummary?.errorMessage ?? "Session failed to start.")
: !activeSandbox?.sandboxId
? selectedForSession.statusMessage
? `Sandbox unavailable: ${selectedForSession.statusMessage}`
: "This task is still provisioning its sandbox."
? "This task is still provisioning its sandbox."
: staleSessionId
? `Session ${staleSessionId} is unavailable. Start a new session to continue.`
: resolvedSessionId
@ -1458,7 +1431,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
<MetaRow label="Branch" value={selectedBranchOverview.branchName} mono />
<MetaRow label="Commit" value={selectedBranchOverview.commitSha.slice(0, 10)} mono />
<MetaRow label="Task" value={selectedBranchOverview.taskTitle ?? selectedBranchOverview.taskId ?? "-"} />
<MetaRow label="PR" value={selectedBranchOverview.prUrl ?? "-"} />
<MetaRow label="PR" value={selectedBranchOverview.pullRequest?.url ?? "-"} />
<MetaRow label="Updated" value={new Date(selectedBranchOverview.updatedAt).toLocaleTimeString()} />
</div>
)}
@ -1504,9 +1477,8 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
})}
>
<MetaRow label="Branch" value={selectedForSession.branch ?? "-"} mono />
<MetaRow label="Diff" value={formatDiffStat(selectedForSession.diffStat)} />
<MetaRow label="PR" value={selectedForSession.prUrl ?? "-"} />
<MetaRow label="Review" value={selectedForSession.reviewStatus ?? "-"} />
<MetaRow label="PR" value={selectedForSession.pullRequest?.url ?? "-"} />
<MetaRow label="Review" value={selectedForSession.pullRequest ? (selectedForSession.pullRequest.isDraft ? "draft" : "ready") : "-"} />
</div>
</section>
@ -1607,25 +1579,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
) : null}
</div>
<div>
<LabelXSmall color="contentSecondary" marginBottom="scale200">
Agent
</LabelXSmall>
<Select
options={AGENT_OPTIONS.map(createOption)}
value={selectValue(selectedAgentOption)}
clearable={false}
searchable={false}
onChange={(params: OnChangeParams) => {
const next = optionId(params.value);
if (next === "claude" || next === "codex") {
setNewAgentType(next);
}
}}
overrides={selectTestIdOverrides("task-create-agent")}
/>
</div>
<div>
<LabelXSmall color="contentSecondary" marginBottom="scale200">
Task

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
import { formatDiffStat, groupTasksByRepo } from "./model";
import { groupTasksByRepo } from "./model";
const base: TaskRecord = {
organizationId: "default",
@ -12,9 +12,8 @@ const base: TaskRecord = {
task: "Ship one",
sandboxProviderId: "local",
status: "running",
statusMessage: null,
activeSandboxId: "sandbox-1",
activeSessionId: "session-1",
pullRequest: null,
sandboxes: [
{
sandboxId: "sandbox-1",
@ -26,17 +25,6 @@ const base: TaskRecord = {
updatedAt: 10,
},
],
agentType: null,
prSubmitted: false,
diffStat: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: null,
hasUnpushed: null,
parentBranch: null,
createdAt: 10,
updatedAt: 10,
};
@ -66,19 +54,3 @@ describe("groupTasksByRepo", () => {
expect(groups[1]?.repoId).toBe("repo-a");
});
});
describe("formatDiffStat", () => {
it("returns No changes for zero-diff values", () => {
expect(formatDiffStat("+0/-0")).toBe("No changes");
expect(formatDiffStat("+0 -0")).toBe("No changes");
});
it("returns dash for empty values", () => {
expect(formatDiffStat(null)).toBe("-");
expect(formatDiffStat("")).toBe("-");
});
it("keeps non-empty non-zero diff stats", () => {
expect(formatDiffStat("+12/-4")).toBe("+12/-4");
});
});

View file

@ -37,14 +37,3 @@ export function groupTasksByRepo(tasks: TaskRecord[]): RepoGroup[] {
return a.repoRemote.localeCompare(b.repoRemote);
});
}
export function formatDiffStat(diffStat: string | null | undefined): string {
const normalized = diffStat?.trim();
if (!normalized) {
return "-";
}
if (normalized === "+0/-0" || normalized === "+0 -0" || normalized === "0 files changed") {
return "No changes";
}
return normalized;
}

View file

@ -4,7 +4,7 @@ import { defaultTaskStatusMessage, deriveHeaderStatus, describeTaskState, isProv
describe("defaultTaskStatusMessage", () => {
it("covers every backend task status", () => {
for (const status of [...TaskStatusSchema.options, "new"] as const) {
for (const status of TaskStatusSchema.options) {
expect(defaultTaskStatusMessage(status)).toMatch(/\S/);
}
});
@ -15,18 +15,14 @@ describe("defaultTaskStatusMessage", () => {
});
describe("resolveTaskStateDetail", () => {
it("prefers the backend status message when present", () => {
expect(resolveTaskStateDetail("init_ensure_name", "determining title and branch")).toBe("determining title and branch");
});
it("falls back to the default copy when the backend message is empty", () => {
expect(resolveTaskStateDetail("init_complete", " ")).toBe("Finalizing task initialization.");
it("returns the default copy for the current task status", () => {
expect(resolveTaskStateDetail("init_complete")).toBe("Finalizing task initialization.");
});
});
describe("describeTaskState", () => {
it("includes the raw backend status code in the title", () => {
expect(describeTaskState("kill_destroy_sandbox", null)).toEqual({
expect(describeTaskState("kill_destroy_sandbox")).toEqual({
title: "Task state: kill_destroy_sandbox",
detail: "Destroying sandbox resources.",
});
@ -52,7 +48,7 @@ describe("isProvisioningTaskStatus", () => {
describe("deriveHeaderStatus", () => {
it("returns error variant when session has error", () => {
const result = deriveHeaderStatus("running", null, "error", "Sandbox crashed");
const result = deriveHeaderStatus("running", "error", "Sandbox crashed");
expect(result.variant).toBe("error");
expect(result.label).toBe("Session error");
expect(result.tooltip).toBe("Sandbox crashed");
@ -60,76 +56,76 @@ describe("deriveHeaderStatus", () => {
});
it("returns error variant when task has error", () => {
const result = deriveHeaderStatus("error", "session:error", null, null);
const result = deriveHeaderStatus("error", null, null);
expect(result.variant).toBe("error");
expect(result.label).toBe("Error");
expect(result.spinning).toBe(false);
});
it("returns warning variant with spinner for provisioning task", () => {
const result = deriveHeaderStatus("init_enqueue_provision", null, null, null);
const result = deriveHeaderStatus("init_enqueue_provision", null, null);
expect(result.variant).toBe("warning");
expect(result.label).toBe("Provisioning");
expect(result.spinning).toBe(true);
});
it("returns warning variant for pending_provision session", () => {
const result = deriveHeaderStatus("running", null, "pending_provision", null);
const result = deriveHeaderStatus("running", "pending_provision", null);
expect(result.variant).toBe("warning");
expect(result.label).toBe("Provisioning");
expect(result.spinning).toBe(true);
});
it("returns warning variant for pending_session_create session", () => {
const result = deriveHeaderStatus("running", null, "pending_session_create", null);
const result = deriveHeaderStatus("running", "pending_session_create", null);
expect(result.variant).toBe("warning");
expect(result.label).toBe("Creating session");
expect(result.spinning).toBe(true);
});
it("returns success variant with spinner for running session", () => {
const result = deriveHeaderStatus("running", null, "running", null);
const result = deriveHeaderStatus("running", "running", null);
expect(result.variant).toBe("success");
expect(result.label).toBe("Running");
expect(result.spinning).toBe(true);
});
it("returns success variant for idle/ready state", () => {
const result = deriveHeaderStatus("idle", null, "idle", null);
const result = deriveHeaderStatus("idle", "idle", null);
expect(result.variant).toBe("success");
expect(result.label).toBe("Ready");
expect(result.spinning).toBe(false);
});
it("returns neutral variant for archived task", () => {
const result = deriveHeaderStatus("archived", null, null, null);
const result = deriveHeaderStatus("archived", null, null);
expect(result.variant).toBe("neutral");
expect(result.label).toBe("Archived");
});
it("session error takes priority over task error", () => {
const result = deriveHeaderStatus("error", "session:error", "error", "Sandbox OOM");
const result = deriveHeaderStatus("error", "error", "Sandbox OOM");
expect(result.variant).toBe("error");
expect(result.label).toBe("Session error");
expect(result.tooltip).toBe("Sandbox OOM");
});
it("returns warning when no sandbox is available", () => {
const result = deriveHeaderStatus("idle", null, "idle", null, false);
const result = deriveHeaderStatus("idle", "idle", null, false);
expect(result.variant).toBe("warning");
expect(result.label).toBe("No sandbox");
expect(result.spinning).toBe(false);
});
it("still shows provisioning when no sandbox but task is provisioning", () => {
const result = deriveHeaderStatus("init_enqueue_provision", null, null, null, false);
const result = deriveHeaderStatus("init_enqueue_provision", null, null, false);
expect(result.variant).toBe("warning");
expect(result.label).toBe("Provisioning");
expect(result.spinning).toBe(true);
});
it("shows error over no-sandbox when session has error", () => {
const result = deriveHeaderStatus("idle", null, "error", "Connection lost", false);
const result = deriveHeaderStatus("idle", "error", "Connection lost", false);
expect(result.variant).toBe("error");
expect(result.label).toBe("Session error");
});

View file

@ -1,7 +1,7 @@
import type { TaskStatus, WorkspaceSessionStatus } from "@sandbox-agent/foundry-shared";
import type { HeaderStatusInfo } from "../../components/mock-layout/ui";
export type TaskDisplayStatus = TaskStatus | "new";
export type TaskDisplayStatus = TaskStatus;
export interface TaskStateDescriptor {
title: string;
@ -9,15 +9,11 @@ export interface TaskStateDescriptor {
}
export function isProvisioningTaskStatus(status: TaskDisplayStatus | null | undefined): boolean {
return (
status === "new" || status === "init_bootstrap_db" || status === "init_enqueue_provision" || status === "init_ensure_name" || status === "init_assert_name"
);
return status === "init_bootstrap_db" || status === "init_enqueue_provision" || status === "init_ensure_name" || status === "init_assert_name";
}
export function defaultTaskStatusMessage(status: TaskDisplayStatus | null | undefined): string {
switch (status) {
case "new":
return "Task created. Waiting to initialize.";
case "init_bootstrap_db":
return "Creating task records.";
case "init_enqueue_provision":
@ -54,15 +50,14 @@ export function defaultTaskStatusMessage(status: TaskDisplayStatus | null | unde
}
}
export function resolveTaskStateDetail(status: TaskDisplayStatus | null | undefined, statusMessage: string | null | undefined): string {
const normalized = statusMessage?.trim();
return normalized && normalized.length > 0 ? normalized : defaultTaskStatusMessage(status);
export function resolveTaskStateDetail(status: TaskDisplayStatus | null | undefined): string {
return defaultTaskStatusMessage(status);
}
export function describeTaskState(status: TaskDisplayStatus | null | undefined, statusMessage: string | null | undefined): TaskStateDescriptor {
export function describeTaskState(status: TaskDisplayStatus | null | undefined): TaskStateDescriptor {
return {
title: status ? `Task state: ${status}` : "Task state unavailable",
detail: resolveTaskStateDetail(status, statusMessage),
detail: resolveTaskStateDetail(status),
};
}
@ -72,7 +67,6 @@ export function describeTaskState(status: TaskDisplayStatus | null | undefined,
*/
export function deriveHeaderStatus(
taskStatus: TaskDisplayStatus | null | undefined,
taskStatusMessage: string | null | undefined,
sessionStatus: WorkspaceSessionStatus | null | undefined,
sessionErrorMessage: string | null | undefined,
hasSandbox?: boolean,
@ -93,7 +87,7 @@ export function deriveHeaderStatus(
variant: "error",
label: "Error",
spinning: false,
tooltip: taskStatusMessage ?? "Task entered an error state.",
tooltip: "Task entered an error state.",
};
}
@ -103,7 +97,7 @@ export function deriveHeaderStatus(
variant: "warning",
label: "No sandbox",
spinning: false,
tooltip: taskStatusMessage ?? "Sandbox is not available for this task.",
tooltip: "Sandbox is not available for this task.",
};
}
@ -113,7 +107,7 @@ export function deriveHeaderStatus(
variant: "warning",
label: "Provisioning",
spinning: true,
tooltip: resolveTaskStateDetail(taskStatus, taskStatusMessage),
tooltip: resolveTaskStateDetail(taskStatus),
};
}

View file

@ -4,6 +4,12 @@ export type FoundryBillingPlanId = "free" | "team";
export type FoundryBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel";
export type FoundryGithubInstallationStatus = "connected" | "install_required" | "reconnect_required";
export type FoundryGithubSyncStatus = "pending" | "syncing" | "synced" | "error";
export type FoundryGithubSyncPhase =
| "discovering_repositories"
| "syncing_repositories"
| "syncing_branches"
| "syncing_members"
| "syncing_pull_requests";
export type FoundryOrganizationKind = "personal" | "organization";
export type FoundryStarterRepoStatus = "pending" | "starred" | "skipped";
@ -53,6 +59,10 @@ export interface FoundryGithubState {
lastSyncAt: number | null;
lastWebhookAt: number | null;
lastWebhookEvent: string;
syncGeneration?: number;
syncPhase?: FoundryGithubSyncPhase | null;
processedRepositoryCount?: number;
totalRepositoryCount?: number;
}
export interface FoundryOrganizationSettings {

View file

@ -58,6 +58,19 @@ export const CreateTaskInputSchema = z.object({
});
export type CreateTaskInput = z.infer<typeof CreateTaskInputSchema>;
export const WorkspacePullRequestSummarySchema = z.object({
number: z.number().int(),
title: z.string().min(1),
state: z.string().min(1),
url: z.string().min(1),
headRefName: z.string().min(1),
baseRefName: z.string().min(1),
repoFullName: z.string().min(1),
authorLogin: z.string().nullable(),
isDraft: z.boolean(),
updatedAtMs: z.number().int(),
});
export const TaskRecordSchema = z.object({
organizationId: OrganizationIdSchema,
repoId: z.string().min(1),
@ -69,6 +82,7 @@ export const TaskRecordSchema = z.object({
sandboxProviderId: SandboxProviderIdSchema,
status: TaskStatusSchema,
activeSandboxId: z.string().nullable(),
pullRequest: WorkspacePullRequestSummarySchema.nullable(),
sandboxes: z.array(
z.object({
sandboxId: z.string().min(1),
@ -80,12 +94,6 @@ export const TaskRecordSchema = z.object({
updatedAt: z.number().int(),
}),
),
diffStat: z.string().nullable(),
prUrl: z.string().nullable(),
prAuthor: z.string().nullable(),
ciStatus: z.string().nullable(),
reviewStatus: z.string().nullable(),
reviewer: z.string().nullable(),
createdAt: z.number().int(),
updatedAt: z.number().int(),
});
@ -99,6 +107,7 @@ export const TaskSummarySchema = z.object({
title: z.string().min(1).nullable(),
status: TaskStatusSchema,
updatedAt: z.number().int(),
pullRequest: WorkspacePullRequestSummarySchema.nullable(),
});
export type TaskSummary = z.infer<typeof TaskSummarySchema>;
@ -129,12 +138,8 @@ export const RepoBranchRecordSchema = z.object({
taskId: z.string().nullable(),
taskTitle: z.string().nullable(),
taskStatus: TaskStatusSchema.nullable(),
prNumber: z.number().int().nullable(),
prState: z.string().nullable(),
prUrl: z.string().nullable(),
pullRequest: WorkspacePullRequestSummarySchema.nullable(),
ciStatus: z.string().nullable(),
reviewStatus: z.string().nullable(),
reviewer: z.string().nullable(),
updatedAt: z.number().int(),
});
export type RepoBranchRecord = z.infer<typeof RepoBranchRecordSchema>;

View file

@ -2,6 +2,7 @@ export * from "./app-shell.js";
export * from "./contracts.js";
export * from "./config.js";
export * from "./logging.js";
export * from "./models.js";
export * from "./realtime-events.js";
export * from "./workspace.js";
export * from "./organization.js";

View file

@ -0,0 +1,217 @@
import claudeConfig from "../../../../scripts/agent-configs/resources/claude.json" with { type: "json" };
import codexConfig from "../../../../scripts/agent-configs/resources/codex.json" with { type: "json" };
export type WorkspaceAgentKind = string;
export type WorkspaceModelId = string;
export interface WorkspaceModelOption {
id: WorkspaceModelId;
label: string;
}
export interface WorkspaceModelGroup {
provider: string;
agentKind: WorkspaceAgentKind;
sandboxAgentId: string;
models: WorkspaceModelOption[];
}
interface AgentConfigResource {
defaultModel?: string;
models?: Array<{ id?: string; name?: string }>;
}
interface SandboxAgentInfoLike {
id?: unknown;
configOptions?: unknown;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function normalizeModelLabel(model: { id?: string; name?: string }): string {
const name = model.name?.trim();
if (name && name.length > 0) {
return name;
}
return model.id?.trim() || "Unknown";
}
function buildGroup(provider: string, agentKind: WorkspaceAgentKind, sandboxAgentId: string, config: AgentConfigResource): WorkspaceModelGroup {
return {
provider,
agentKind,
sandboxAgentId,
models: (config.models ?? [])
.map((model) => {
const id = model.id?.trim();
if (!id) {
return null;
}
return {
id,
label: normalizeModelLabel(model),
};
})
.filter((model): model is WorkspaceModelOption => model != null),
};
}
function titleCaseIdentifier(value: string): string {
return value
.split(/[\s_-]+/)
.filter(Boolean)
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
.join(" ");
}
function workspaceAgentMetadata(agentId: string): { provider: string; agentKind: string } {
const normalized = agentId.trim().toLowerCase();
switch (normalized) {
case "claude":
return { provider: "Claude", agentKind: "Claude" };
case "codex":
return { provider: "Codex", agentKind: "Codex" };
default:
return {
provider: titleCaseIdentifier(agentId),
agentKind: titleCaseIdentifier(agentId),
};
}
}
function normalizeOptionLabel(entry: Record<string, unknown>): string | null {
const name = typeof entry.name === "string" ? entry.name.trim() : "";
if (name) {
return name;
}
const label = typeof entry.label === "string" ? entry.label.trim() : "";
if (label) {
return label;
}
const value = typeof entry.value === "string" ? entry.value.trim() : "";
return value || null;
}
function appendSelectOptionModels(target: WorkspaceModelOption[], options: unknown): void {
if (!Array.isArray(options)) {
return;
}
for (const entry of options) {
if (!isRecord(entry)) {
continue;
}
const value = typeof entry.value === "string" ? entry.value.trim() : "";
if (value) {
target.push({
id: value,
label: normalizeOptionLabel(entry) ?? value,
});
continue;
}
appendSelectOptionModels(target, entry.options);
}
}
function normalizeAgentModels(configOptions: unknown): WorkspaceModelOption[] {
if (!Array.isArray(configOptions)) {
return [];
}
const options = configOptions.find((entry) => isRecord(entry) && entry.category === "model" && entry.type === "select");
if (!isRecord(options)) {
return [];
}
const models: WorkspaceModelOption[] = [];
appendSelectOptionModels(models, options.options);
const seen = new Set<string>();
return models.filter((model) => {
if (!model.id || seen.has(model.id)) {
return false;
}
seen.add(model.id);
return true;
});
}
export function workspaceModelGroupsFromSandboxAgents(agents: SandboxAgentInfoLike[]): WorkspaceModelGroup[] {
return agents
.map((agent) => {
const sandboxAgentId = typeof agent.id === "string" ? agent.id.trim() : "";
if (!sandboxAgentId) {
return null;
}
const models = normalizeAgentModels(agent.configOptions);
if (models.length === 0) {
return null;
}
const metadata = workspaceAgentMetadata(sandboxAgentId);
return {
provider: metadata.provider,
agentKind: metadata.agentKind,
sandboxAgentId,
models,
} satisfies WorkspaceModelGroup;
})
.filter((group): group is WorkspaceModelGroup => group != null);
}
export const DEFAULT_WORKSPACE_MODEL_GROUPS: WorkspaceModelGroup[] = [
buildGroup("Claude", "Claude", "claude", claudeConfig as AgentConfigResource),
buildGroup("Codex", "Codex", "codex", codexConfig as AgentConfigResource),
].filter((group) => group.models.length > 0);
export const DEFAULT_WORKSPACE_MODEL_ID: WorkspaceModelId =
((codexConfig as AgentConfigResource).defaultModel ?? DEFAULT_WORKSPACE_MODEL_GROUPS[0]?.models[0]?.id ?? "default").trim();
export function workspaceProviderAgent(
provider: string,
groups: WorkspaceModelGroup[] = DEFAULT_WORKSPACE_MODEL_GROUPS,
): WorkspaceAgentKind {
return groups.find((group) => group.provider === provider)?.agentKind ?? provider;
}
export function workspaceModelGroupForId(
id: WorkspaceModelId,
groups: WorkspaceModelGroup[] = DEFAULT_WORKSPACE_MODEL_GROUPS,
): WorkspaceModelGroup | null {
return groups.find((group) => group.models.some((model) => model.id === id)) ?? null;
}
export function workspaceModelLabel(
id: WorkspaceModelId,
groups: WorkspaceModelGroup[] = DEFAULT_WORKSPACE_MODEL_GROUPS,
): string {
const group = workspaceModelGroupForId(id, groups);
const model = group?.models.find((candidate) => candidate.id === id);
return model && group ? `${group.provider} ${model.label}` : id;
}
export function workspaceAgentForModel(
id: WorkspaceModelId,
groups: WorkspaceModelGroup[] = DEFAULT_WORKSPACE_MODEL_GROUPS,
): WorkspaceAgentKind {
const group = workspaceModelGroupForId(id, groups);
if (group) {
return group.agentKind;
}
return groups[0]?.agentKind ?? "Claude";
}
export function workspaceSandboxAgentIdForModel(
id: WorkspaceModelId,
groups: WorkspaceModelGroup[] = DEFAULT_WORKSPACE_MODEL_GROUPS,
): string {
const group = workspaceModelGroupForId(id, groups);
return group?.sandboxAgentId ?? groups[0]?.sandboxAgentId ?? "claude";
}

View file

@ -1,18 +1,11 @@
import type { SandboxProviderId, TaskStatus } from "./contracts.js";
import type { WorkspaceAgentKind, WorkspaceModelGroup, WorkspaceModelId, WorkspaceModelOption } from "./models.js";
export type WorkspaceTaskStatus = TaskStatus | "new";
export type WorkspaceAgentKind = "Claude" | "Codex" | "Cursor";
export type WorkspaceModelId =
| "claude-sonnet-4"
| "claude-opus-4"
| "gpt-5.3-codex"
| "gpt-5.4"
| "gpt-5.2-codex"
| "gpt-5.1-codex-max"
| "gpt-5.2"
| "gpt-5.1-codex-mini";
export type WorkspaceTaskStatus = TaskStatus;
export type WorkspaceSessionStatus = "pending_provision" | "pending_session_create" | "ready" | "running" | "idle" | "error";
export type { WorkspaceAgentKind, WorkspaceModelGroup, WorkspaceModelId, WorkspaceModelOption } from "./models.js";
export interface WorkspaceTranscriptEvent {
id: string;
eventIndex: number;
@ -132,6 +125,7 @@ export interface WorkspaceTaskSummary {
updatedAtMs: number;
branch: string | null;
pullRequest: WorkspacePullRequestSummary | null;
activeSessionId: string | null;
/** Summary of sessions — no transcript content. */
sessionsSummary: WorkspaceSessionSummary[];
}
@ -140,11 +134,6 @@ export interface WorkspaceTaskSummary {
export interface WorkspaceTaskDetail extends WorkspaceTaskSummary {
/** Original task prompt/instructions shown in the detail view. */
task: string;
/** Underlying task runtime status preserved for detail views and error handling. */
runtimeStatus: TaskStatus;
diffStat: string | null;
prUrl: string | null;
reviewStatus: string | null;
fileChanges: WorkspaceFileChange[];
diffs: Record<string, string>;
fileTree: WorkspaceFileTreeNode[];
@ -163,9 +152,32 @@ export interface WorkspaceRepositorySummary {
latestActivityMs: number;
}
export type OrganizationGithubSyncPhase =
| "discovering_repositories"
| "syncing_repositories"
| "syncing_branches"
| "syncing_members"
| "syncing_pull_requests";
export interface OrganizationGithubSummary {
connectedAccount: string;
installationStatus: "connected" | "install_required" | "reconnect_required";
syncStatus: "pending" | "syncing" | "synced" | "error";
importedRepoCount: number;
lastSyncLabel: string;
lastSyncAt: number | null;
lastWebhookAt: number | null;
lastWebhookEvent: string;
syncGeneration: number;
syncPhase: OrganizationGithubSyncPhase | null;
processedRepositoryCount: number;
totalRepositoryCount: number;
}
/** Organization-level snapshot — initial fetch for the organization topic. */
export interface OrganizationSummarySnapshot {
organizationId: string;
github: OrganizationGithubSummary;
repos: WorkspaceRepositorySummary[];
taskSummaries: WorkspaceTaskSummary[];
}
@ -180,11 +192,11 @@ export interface WorkspaceTask {
repoId: string;
title: string;
status: WorkspaceTaskStatus;
runtimeStatus?: TaskStatus;
repoName: string;
updatedAtMs: number;
branch: string | null;
pullRequest: WorkspacePullRequestSummary | null;
activeSessionId?: string | null;
sessions: WorkspaceSession[];
fileChanges: WorkspaceFileChange[];
diffs: Record<string, string>;
@ -212,16 +224,6 @@ export interface TaskWorkspaceSnapshot {
tasks: WorkspaceTask[];
}
export interface WorkspaceModelOption {
id: WorkspaceModelId;
label: string;
}
export interface WorkspaceModelGroup {
provider: string;
models: WorkspaceModelOption[];
}
export interface TaskWorkspaceSelectInput {
repoId: string;
taskId: string;