feat(foundry): checkpoint actor and workspace refactor

This commit is contained in:
Nathan Flurry 2026-03-15 10:20:27 -07:00
parent 32f3c6c3bc
commit dbe57d45b9
81 changed files with 3441 additions and 2332 deletions

View file

@ -6,13 +6,13 @@ Keep the backend actor tree aligned with this shape unless we explicitly decide
```text
OrganizationActor
├─ HistoryActor(organization-scoped global feed)
├─ AuditLogActor(organization-scoped global feed)
├─ GithubDataActor
├─ RepositoryActor(repo)
│ └─ TaskActor(task)
│ ├─ TaskSessionActor(session) × N
│ │ └─ SessionStatusSyncActor(session) × 0..1
│ └─ Task-local workbench state
│ └─ Task-local workspace state
└─ SandboxInstanceActor(sandboxProviderId, sandboxId) × N
```
@ -46,12 +46,12 @@ OrganizationActor (coordinator for repos + auth users)
│ └─ TaskActor (coordinator for sessions + sandboxes)
│ │
│ │ Index tables:
│ │ ├─ taskWorkbenchSessions → Session index (session metadata, transcript, draft)
│ │ ├─ taskWorkspaceSessions → Session index (session metadata, transcript, draft)
│ │ └─ taskSandboxes → SandboxInstanceActor index (sandbox history)
│ │
│ └─ SandboxInstanceActor (leaf)
├─ HistoryActor (organization-scoped audit log, not a coordinator)
├─ AuditLogActor (organization-scoped audit log, not a coordinator)
└─ GithubDataActor (GitHub API cache, not a coordinator)
```
@ -60,13 +60,13 @@ When adding a new index table, annotate it in the schema file with a doc comment
## Ownership Rules
- `OrganizationActor` is the organization coordinator and lookup/index owner.
- `HistoryActor` is organization-scoped. There is one organization-level history feed.
- `AuditLogActor` is organization-scoped. There is one organization-level audit log feed.
- `RepositoryActor` is the repo coordinator and owns repo-local caches/indexes.
- `TaskActor` is one branch. Treat `1 task = 1 branch` once branch assignment is finalized.
- `TaskActor` can have many sessions.
- `TaskActor` can reference many sandbox instances historically, but should have only one active sandbox/session at a time.
- Session unread state and draft prompts are backend-owned workbench state, not frontend-local state.
- Branch rename is a real git operation, not just metadata.
- Session unread state and draft prompts are backend-owned workspace state, not frontend-local state.
- Branch names are immutable after task creation. Do not implement branch-rename flows.
- `SandboxInstanceActor` stays separate from `TaskActor`; tasks/sessions reference it by identity.
- The backend stores no local git state. No clones, no refs, no working trees, and no git-spice. Repository metadata comes from GitHub API data and webhook events. Any working-tree git operation runs inside a sandbox via `executeInSandbox()`.
- When a backend request path must aggregate multiple independent actor calls or reads, prefer bounded parallelism over sequential fan-out when correctness permits. Do not serialize independent work by default.
@ -75,6 +75,11 @@ When adding a new index table, annotate it in the schema file with a doc comment
- Read paths must use the coordinator's local index tables. Do not fan out to child actors on the hot read path.
- Never build "enriched" read actions that chain through multiple actors (e.g., coordinator → child actor → sibling actor). If data from multiple actors is needed for a read, it should already be materialized in the coordinator's index tables via push updates. If it's not there, fix the write path to push it — do not add a fan-out read path.
## SQLite Constraints
- Single-row tables must use an integer primary key with `CHECK (id = 1)` to enforce the singleton invariant at the database level.
- Follow the task actor pattern for metadata/profile rows and keep the fixed row id in code as `1`, not a string sentinel.
## Multiplayer Correctness
Per-user UI state must live on the user actor, not on shared task/session actors. This is critical for multiplayer — multiple users may view the same task simultaneously with different active sessions, unread states, and in-progress drafts.
@ -85,6 +90,10 @@ Per-user UI state must live on the user actor, not on shared task/session actors
Do not store per-user preferences, selections, or ephemeral UI state on shared actors. If a field's value should differ between two users looking at the same task, it belongs on the user actor.
## Audit Log Maintenance
Every new action or command handler that represents a user-visible or workflow-significant event must append to the audit log actor. The audit log must remain a comprehensive record of significant operations.
## Maintenance
- Keep this file up to date whenever actor ownership, hierarchy, or lifecycle responsibilities change.

View file

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

View file

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

View file

@ -5,7 +5,7 @@ export const events = sqliteTable("events", {
taskId: text("task_id"),
branchName: text("branch_name"),
kind: text("kind").notNull(),
// Structured by the history event kind definitions in application code.
// Structured by the audit-log event kind definitions in application code.
payloadJson: text("payload_json").notNull(),
createdAt: integer("created_at").notNull(),
});

View file

@ -2,32 +2,31 @@
import { and, desc, eq } from "drizzle-orm";
import { actor, queue } from "rivetkit";
import { Loop, workflow } from "rivetkit/workflow";
import type { HistoryEvent } from "@sandbox-agent/foundry-shared";
import { selfHistory } from "../handles.js";
import { historyDb } from "./db/db.js";
import type { AuditLogEvent } from "@sandbox-agent/foundry-shared";
import { auditLogDb } from "./db/db.js";
import { events } from "./db/schema.js";
export interface HistoryInput {
export interface AuditLogInput {
organizationId: string;
repoId: string;
}
export interface AppendHistoryCommand {
export interface AppendAuditLogCommand {
kind: string;
taskId?: string;
branchName?: string;
payload: Record<string, unknown>;
}
export interface ListHistoryParams {
export interface ListAuditLogParams {
branch?: string;
taskId?: string;
limit?: number;
}
const HISTORY_QUEUE_NAMES = ["history.command.append"] as const;
export const AUDIT_LOG_QUEUE_NAMES = ["auditLog.command.append"] as const;
async function appendHistoryRow(loopCtx: any, body: AppendHistoryCommand): Promise<void> {
async function appendAuditLogRow(loopCtx: any, body: AppendAuditLogCommand): Promise<void> {
const now = Date.now();
await loopCtx.db
.insert(events)
@ -41,18 +40,18 @@ async function appendHistoryRow(loopCtx: any, body: AppendHistoryCommand): Promi
.run();
}
async function runHistoryWorkflow(ctx: any): Promise<void> {
await ctx.loop("history-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-history-command", {
names: [...HISTORY_QUEUE_NAMES],
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 === "history.command.append") {
await loopCtx.step("append-history-row", async () => appendHistoryRow(loopCtx, msg.body as AppendHistoryCommand));
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 });
}
@ -60,26 +59,21 @@ async function runHistoryWorkflow(ctx: any): Promise<void> {
});
}
export const history = actor({
db: historyDb,
export const auditLog = actor({
db: auditLogDb,
queues: {
"history.command.append": queue(),
"auditLog.command.append": queue(),
},
options: {
name: "History",
name: "Audit Log",
icon: "database",
},
createState: (_c, input: HistoryInput) => ({
createState: (_c, input: AuditLogInput) => ({
organizationId: input.organizationId,
repoId: input.repoId,
}),
actions: {
async append(c, command: AppendHistoryCommand): Promise<void> {
const self = selfHistory(c);
await self.send("history.command.append", command, { wait: true, timeout: 15_000 });
},
async list(c, params?: ListHistoryParams): Promise<HistoryEvent[]> {
async list(c, params?: ListAuditLogParams): Promise<AuditLogEvent[]> {
const whereParts = [];
if (params?.taskId) {
whereParts.push(eq(events.taskId, params.taskId));
@ -111,5 +105,5 @@ export const history = actor({
}));
},
},
run: workflow(runHistoryWorkflow),
run: workflow(runAuditLogWorkflow),
});

View file

@ -1,70 +0,0 @@
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
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 authSessions = sqliteTable(
"session",
{
id: text("id").notNull().primaryKey(),
token: text("token").notNull(),
userId: text("user_id").notNull(),
expiresAt: integer("expires_at").notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
},
(table) => ({
tokenIdx: uniqueIndex("session_token_idx").on(table.token),
}),
);
export const authAccounts = sqliteTable(
"account",
{
id: text("id").notNull().primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id").notNull(),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: integer("access_token_expires_at"),
refreshTokenExpiresAt: integer("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
},
(table) => ({
providerAccountIdx: uniqueIndex("account_provider_account_idx").on(table.providerId, table.accountId),
}),
);
export const userProfiles = sqliteTable("user_profiles", {
userId: text("user_id").notNull().primaryKey(),
githubAccountId: text("github_account_id"),
githubLogin: text("github_login"),
roleLabel: text("role_label").notNull(),
eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(),
starterRepoStatus: text("starter_repo_status").notNull(),
starterRepoStarredAt: integer("starter_repo_starred_at"),
starterRepoSkippedAt: integer("starter_repo_skipped_at"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const sessionState = sqliteTable("session_state", {
sessionId: text("session_id").notNull().primaryKey(),
activeOrganizationId: text("active_organization_id"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});

View file

@ -32,7 +32,8 @@ export default {
\`installation_id\` integer,
\`last_sync_label\` text NOT NULL,
\`last_sync_at\` integer,
\`updated_at\` integer NOT NULL
\`updated_at\` integer NOT NULL,
CONSTRAINT \`github_meta_singleton_id_check\` CHECK(\`id\` = 1)
);
--> statement-breakpoint
CREATE TABLE \`github_repositories\` (

View file

@ -1,15 +1,20 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
import { check, integer, sqliteTable, text } from "rivetkit/db/drizzle";
import { sql } from "drizzle-orm";
export const githubMeta = sqliteTable("github_meta", {
id: integer("id").primaryKey(),
connectedAccount: text("connected_account").notNull(),
installationStatus: text("installation_status").notNull(),
syncStatus: text("sync_status").notNull(),
installationId: integer("installation_id"),
lastSyncLabel: text("last_sync_label").notNull(),
lastSyncAt: integer("last_sync_at"),
updatedAt: integer("updated_at").notNull(),
});
export const githubMeta = sqliteTable(
"github_meta",
{
id: integer("id").primaryKey(),
connectedAccount: text("connected_account").notNull(),
installationStatus: text("installation_status").notNull(),
syncStatus: text("sync_status").notNull(),
installationId: integer("installation_id"),
lastSyncLabel: text("last_sync_label").notNull(),
lastSyncAt: integer("last_sync_at"),
updatedAt: integer("updated_at").notNull(),
},
(table) => [check("github_meta_singleton_id_check", sql`${table.id} = 1`)],
);
export const githubRepositories = sqliteTable("github_repositories", {
repoId: text("repo_id").notNull().primaryKey(),

View file

@ -681,15 +681,15 @@ export const githubData = actor({
};
},
async fullSync(c, input: FullSyncInput = {}) {
async adminFullSync(c, input: FullSyncInput = {}) {
return await runFullSync(c, input);
},
async reloadOrganization(c) {
async adminReloadOrganization(c) {
return await runFullSync(c, { label: "Reloading GitHub organization..." });
},
async reloadAllPullRequests(c) {
async adminReloadAllPullRequests(c) {
return await runFullSync(c, { label: "Reloading GitHub pull requests..." });
},
@ -846,7 +846,7 @@ export const githubData = actor({
);
},
async clearState(c, input: ClearStateInput) {
async adminClearState(c, input: ClearStateInput) {
const beforeRows = await readAllPullRequestRows(c);
await c.db.delete(githubPullRequests).run();
await c.db.delete(githubBranches).run();

View file

@ -1,4 +1,4 @@
import { authUserKey, githubDataKey, historyKey, organizationKey, repositoryKey, taskKey, taskSandboxKey } from "./keys.js";
import { auditLogKey, githubDataKey, organizationKey, repositoryKey, taskKey, taskSandboxKey, userKey } from "./keys.js";
export function actorClient(c: any) {
return c.client();
@ -10,14 +10,14 @@ export async function getOrCreateOrganization(c: any, organizationId: string) {
});
}
export async function getOrCreateAuthUser(c: any, userId: string) {
return await actorClient(c).authUser.getOrCreate(authUserKey(userId), {
export async function getOrCreateUser(c: any, userId: string) {
return await actorClient(c).user.getOrCreate(userKey(userId), {
createWithInput: { userId },
});
}
export function getAuthUser(c: any, userId: string) {
return actorClient(c).authUser.get(authUserKey(userId));
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) {
@ -44,8 +44,8 @@ export async function getOrCreateTask(c: any, organizationId: string, repoId: st
});
}
export async function getOrCreateHistory(c: any, organizationId: string, repoId: string) {
return await actorClient(c).history.getOrCreate(historyKey(organizationId, repoId), {
export async function getOrCreateAuditLog(c: any, organizationId: string, repoId: string) {
return await actorClient(c).auditLog.getOrCreate(auditLogKey(organizationId, repoId), {
createWithInput: {
organizationId,
repoId,
@ -75,8 +75,8 @@ export async function getOrCreateTaskSandbox(c: any, organizationId: string, san
});
}
export function selfHistory(c: any) {
return actorClient(c).history.getForId(c.actorId);
export function selfAuditLog(c: any) {
return actorClient(c).auditLog.getForId(c.actorId);
}
export function selfTask(c: any) {
@ -91,8 +91,8 @@ export function selfRepository(c: any) {
return actorClient(c).repository.getForId(c.actorId);
}
export function selfAuthUser(c: any) {
return actorClient(c).authUser.getForId(c.actorId);
export function selfUser(c: any) {
return actorClient(c).user.getForId(c.actorId);
}
export function selfGithubData(c: any) {

View file

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

View file

@ -1,8 +1,8 @@
import { authUser } from "./auth-user/index.js";
import { user } from "./user/index.js";
import { setup } from "rivetkit";
import { githubData } from "./github-data/index.js";
import { task } from "./task/index.js";
import { history } from "./history/index.js";
import { auditLog } from "./audit-log/index.js";
import { repository } from "./repository/index.js";
import { taskSandbox } from "./sandbox/index.js";
import { organization } from "./organization/index.js";
@ -21,22 +21,22 @@ export const registry = setup({
baseLogger: logger,
},
use: {
authUser,
user,
organization,
repository,
task,
taskSandbox,
history,
auditLog,
githubData,
},
});
export * from "./context.js";
export * from "./events.js";
export * from "./auth-user/index.js";
export * from "./audit-log/index.js";
export * from "./user/index.js";
export * from "./github-data/index.js";
export * from "./task/index.js";
export * from "./history/index.js";
export * from "./keys.js";
export * from "./repository/index.js";
export * from "./sandbox/index.js";

View file

@ -4,7 +4,7 @@ export function organizationKey(organizationId: string): ActorKey {
return ["org", organizationId];
}
export function authUserKey(userId: string): ActorKey {
export function userKey(userId: string): ActorKey {
return ["org", "app", "user", userId];
}
@ -20,8 +20,8 @@ export function taskSandboxKey(organizationId: string, sandboxId: string): Actor
return ["org", organizationId, "sandbox", sandboxId];
}
export function historyKey(organizationId: string, repoId: string): ActorKey {
return ["org", organizationId, "repository", repoId, "history"];
export function auditLogKey(organizationId: string, repoId: string): ActorKey {
return ["org", organizationId, "repository", repoId, "audit-log"];
}
export function githubDataKey(organizationId: string): ActorKey {

View file

@ -3,7 +3,7 @@ import { desc, eq } from "drizzle-orm";
import { Loop } from "rivetkit/workflow";
import type {
CreateTaskInput,
HistoryEvent,
AuditLogEvent,
HistoryQueryInput,
ListTasksInput,
SandboxProviderId,
@ -14,32 +14,30 @@ import type {
SwitchResult,
TaskRecord,
TaskSummary,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSessionInput,
TaskWorkbenchUpdateDraftInput,
WorkbenchOpenPrSummary,
WorkbenchRepositorySummary,
WorkbenchSessionSummary,
WorkbenchTaskSummary,
TaskWorkspaceChangeModelInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceDiffInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSelectInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceSessionInput,
TaskWorkspaceUpdateDraftInput,
WorkspaceRepositorySummary,
WorkspaceTaskSummary,
OrganizationEvent,
OrganizationSummarySnapshot,
OrganizationUseInput,
} from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../context.js";
import { getGithubData, getOrCreateGithubData, getTask, getOrCreateHistory, getOrCreateRepository, selfOrganization } from "../handles.js";
import { getGithubData, getOrCreateAuditLog, getOrCreateGithubData, getTask as getTaskHandle, getOrCreateRepository, selfOrganization } 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, taskLookup, repos, taskSummaries } from "./db/schema.js";
import { agentTypeForModel } from "../task/workbench.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";
@ -49,6 +47,7 @@ interface OrganizationState {
interface GetTaskInput {
organizationId: string;
repoId?: string;
taskId: string;
}
@ -72,7 +71,7 @@ export function organizationWorkflowQueueName(name: OrganizationQueueName): Orga
return name;
}
const ORGANIZATION_PROFILE_ROW_ID = "profile";
const ORGANIZATION_PROFILE_ROW_ID = 1;
function assertOrganization(c: { state: OrganizationState }, organizationId: string): void {
if (organizationId !== c.state.organizationId) {
@ -80,42 +79,6 @@ function assertOrganization(c: { state: OrganizationState }, organizationId: str
}
}
async function resolveRepoId(c: any, taskId: string): Promise<string> {
const row = await c.db.select({ repoId: taskLookup.repoId }).from(taskLookup).where(eq(taskLookup.taskId, taskId)).get();
if (!row) {
throw new Error(`Unknown task: ${taskId} (not in lookup)`);
}
return row.repoId;
}
async function upsertTaskLookupRow(c: any, taskId: string, repoId: string): Promise<void> {
await c.db
.insert(taskLookup)
.values({
taskId,
repoId,
})
.onConflictDoUpdate({
target: taskLookup.taskId,
set: { repoId },
})
.run();
}
function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
if (!value) {
return fallback;
}
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
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();
@ -152,7 +115,7 @@ function repoLabelFromRemote(remoteUrl: string): string {
return remoteUrl;
}
function buildRepoSummary(repoRow: { repoId: string; remoteUrl: string; updatedAt: number }, taskRows: WorkbenchTaskSummary[]): WorkbenchRepositorySummary {
function buildRepoSummary(repoRow: { repoId: string; remoteUrl: string; updatedAt: number }, taskRows: WorkspaceTaskSummary[]): WorkspaceRepositorySummary {
const repoTasks = taskRows.filter((task) => task.repoId === repoRow.repoId);
const latestActivityMs = repoTasks.reduce((latest, task) => Math.max(latest, task.updatedAtMs), repoRow.updatedAt);
@ -164,79 +127,42 @@ function buildRepoSummary(repoRow: { repoId: string; remoteUrl: string; updatedA
};
}
function taskSummaryRowFromSummary(taskSummary: WorkbenchTaskSummary) {
return {
taskId: taskSummary.id,
repoId: taskSummary.repoId,
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 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}`);
}
function taskSummaryFromRow(row: any): WorkbenchTaskSummary {
return {
id: row.taskId,
repoId: row.repoId,
title: row.title,
status: row.status,
repoName: row.repoName,
updatedAtMs: row.updatedAtMs,
branch: row.branch ?? null,
pullRequest: parseJsonValue(row.pullRequestJson, null),
sessionsSummary: parseJsonValue<WorkbenchSessionSummary[]>(row.sessionsSummaryJson, []),
};
}
async function listOpenPullRequestsSnapshot(c: any, taskRows: WorkbenchTaskSummary[]): Promise<WorkbenchOpenPrSummary[]> {
const githubData = getGithubData(c, c.state.organizationId);
const openPullRequests = await githubData.listOpenPullRequests({}).catch(() => []);
const claimedBranches = new Set(taskRows.filter((task) => task.branch).map((task) => `${task.repoId}:${task.branch}`));
return openPullRequests.filter((pullRequest: WorkbenchOpenPrSummary) => !claimedBranches.has(`${pullRequest.repoId}:${pullRequest.headRefName}`));
}
async function reconcileWorkbenchProjection(c: any): Promise<OrganizationSummarySnapshot> {
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: WorkbenchTaskSummary[] = [];
const taskRows: WorkspaceTaskSummary[] = [];
for (const row of repoRows) {
try {
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl);
const summaries = await repository.listTaskSummaries({ includeArchived: true });
for (const summary of summaries) {
try {
await upsertTaskLookupRow(c, summary.taskId, row.repoId);
const task = getTask(c, c.state.organizationId, row.repoId, summary.taskId);
const taskSummary = await task.getTaskSummary({});
taskRows.push(taskSummary);
await c.db
.insert(taskSummaries)
.values(taskSummaryRowFromSummary(taskSummary))
.onConflictDoUpdate({
target: taskSummaries.taskId,
set: taskSummaryRowFromSummary(taskSummary),
})
.run();
} catch (error) {
logActorWarning("organization", "failed collecting task summary during reconciliation", {
organizationId: c.state.organizationId,
repoId: row.repoId,
taskId: summary.taskId,
error: resolveErrorMessage(error),
});
}
}
taskRows.push(...(await repository.listWorkspaceTaskSummaries({})));
} catch (error) {
logActorWarning("organization", "failed collecting repo during workbench reconciliation", {
logActorWarning("organization", "failed collecting repo during workspace reconciliation", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
@ -249,19 +175,17 @@ async function reconcileWorkbenchProjection(c: any): Promise<OrganizationSummary
organizationId: c.state.organizationId,
repos: repoRows.map((row) => buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
taskSummaries: taskRows,
openPullRequests: await listOpenPullRequestsSnapshot(c, taskRows),
};
}
async function requireWorkbenchTask(c: any, taskId: string) {
const repoId = await resolveRepoId(c, taskId);
return getTask(c, c.state.organizationId, repoId, taskId);
async function requireWorkspaceTask(c: any, repoId: string, taskId: string) {
return getTaskHandle(c, c.state.organizationId, repoId, taskId);
}
/**
* Reads the organization sidebar snapshot from the organization actor's local SQLite
* plus the org-scoped GitHub actor for open PRs. Task actors still push
* summary updates into `task_summaries`, so the hot read path stays bounded.
* 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 repoRows = await c.db
@ -273,25 +197,33 @@ async function getOrganizationSummarySnapshot(c: any): Promise<OrganizationSumma
.from(repos)
.orderBy(desc(repos.updatedAt))
.all();
const taskRows = await c.db.select().from(taskSummaries).orderBy(desc(taskSummaries.updatedAtMs)).all();
const summaries = taskRows.map(taskSummaryFromRow);
const summaries: WorkspaceTaskSummary[] = [];
for (const row of repoRows) {
try {
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl);
summaries.push(...(await repository.listWorkspaceTaskSummaries({})));
} catch (error) {
logActorWarning("organization", "failed reading repository task projection", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
summaries.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
return {
organizationId: c.state.organizationId,
repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
taskSummaries: summaries,
openPullRequests: await listOpenPullRequestsSnapshot(c, summaries),
};
}
async function broadcastRepoSummary(
c: any,
type: "repoAdded" | "repoUpdated",
repoRow: { repoId: string; remoteUrl: string; updatedAt: number },
): Promise<void> {
const matchingTaskRows = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, repoRow.repoId)).all();
const repo = buildRepoSummary(repoRow, matchingTaskRows.map(taskSummaryFromRow));
c.broadcast("organizationUpdated", { type, repo } satisfies OrganizationEvent);
async function broadcastOrganizationSnapshot(c: any): Promise<void> {
c.broadcast("organizationUpdated", {
type: "organizationUpdated",
snapshot: await getOrganizationSummarySnapshot(c),
} satisfies OrganizationEvent);
}
async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskRecord> {
@ -318,32 +250,6 @@ async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskR
onBranch: input.onBranch ?? null,
});
await c.db
.insert(taskLookup)
.values({
taskId: created.taskId,
repoId,
})
.onConflictDoUpdate({
target: taskLookup.taskId,
set: { repoId },
})
.run();
try {
const task = getTask(c, c.state.organizationId, repoId, created.taskId);
await organizationActions.applyTaskSummaryUpdate(c, {
taskSummary: await task.getTaskSummary({}),
});
} catch (error) {
logActorWarning("organization", "failed seeding task summary after task creation", {
organizationId: c.state.organizationId,
repoId,
taskId: created.taskId,
error: resolveErrorMessage(error),
});
}
return created;
}
@ -451,67 +357,8 @@ export const organizationActions = {
};
},
/**
* Called by task actors when their summary-level state changes.
* This is the write path for the local materialized projection; clients read
* the projection via `getOrganizationSummary`, but only task actors should push
* rows into it.
*/
async applyTaskSummaryUpdate(c: any, input: { taskSummary: WorkbenchTaskSummary }): Promise<void> {
await c.db
.insert(taskSummaries)
.values(taskSummaryRowFromSummary(input.taskSummary))
.onConflictDoUpdate({
target: taskSummaries.taskId,
set: taskSummaryRowFromSummary(input.taskSummary),
})
.run();
c.broadcast("organizationUpdated", { type: "taskSummaryUpdated", taskSummary: input.taskSummary } satisfies OrganizationEvent);
},
async removeTaskSummary(c: any, input: { taskId: string }): Promise<void> {
await c.db.delete(taskSummaries).where(eq(taskSummaries.taskId, input.taskId)).run();
c.broadcast("organizationUpdated", { type: "taskRemoved", taskId: input.taskId } satisfies OrganizationEvent);
},
async findTaskForGithubBranch(c: any, input: { repoId: string; branchName: string }): Promise<{ taskId: string | null }> {
const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.repoId)).all();
const existing = summaries.find((summary) => summary.branch === input.branchName);
return { taskId: existing?.taskId ?? null };
},
async refreshTaskSummaryForGithubBranch(c: any, input: { repoId: string; branchName: string }): Promise<void> {
const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.repoId)).all();
const matches = summaries.filter((summary) => summary.branch === input.branchName);
for (const summary of matches) {
try {
const task = getTask(c, c.state.organizationId, input.repoId, summary.taskId);
await organizationActions.applyTaskSummaryUpdate(c, {
taskSummary: await task.getTaskSummary({}),
});
} catch (error) {
logActorWarning("organization", "failed refreshing task summary for GitHub branch", {
organizationId: c.state.organizationId,
repoId: input.repoId,
branchName: input.branchName,
taskId: summary.taskId,
error: resolveErrorMessage(error),
});
}
}
},
async applyOpenPullRequestUpdate(c: any, input: { pullRequest: WorkbenchOpenPrSummary }): Promise<void> {
const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.pullRequest.repoId)).all();
if (summaries.some((summary) => summary.branch === input.pullRequest.headRefName)) {
return;
}
c.broadcast("organizationUpdated", { type: "pullRequestUpdated", pullRequest: input.pullRequest } satisfies OrganizationEvent);
},
async removeOpenPullRequest(c: any, input: { prId: string }): Promise<void> {
c.broadcast("organizationUpdated", { type: "pullRequestRemoved", prId: input.prId } satisfies OrganizationEvent);
async refreshOrganizationSnapshot(c: any): Promise<void> {
await broadcastOrganizationSnapshot(c);
},
async applyGithubRepositoryProjection(c: any, input: { repoId: string; remoteUrl: string }): Promise<void> {
@ -533,11 +380,7 @@ export const organizationActions = {
},
})
.run();
await broadcastRepoSummary(c, existing ? "repoUpdated" : "repoAdded", {
repoId: input.repoId,
remoteUrl: input.remoteUrl,
updatedAt: now,
});
await broadcastOrganizationSnapshot(c);
},
async applyGithubDataProjection(
@ -576,11 +419,7 @@ export const organizationActions = {
},
})
.run();
await broadcastRepoSummary(c, existingById.has(repoId) ? "repoUpdated" : "repoAdded", {
repoId,
remoteUrl: repository.cloneUrl,
updatedAt: now,
});
await broadcastOrganizationSnapshot(c);
}
for (const repo of existingRepos) {
@ -588,7 +427,7 @@ export const organizationActions = {
continue;
}
await c.db.delete(repos).where(eq(repos.repoId, repo.repoId)).run();
c.broadcast("organizationUpdated", { type: "repoRemoved", repoId: repo.repoId } satisfies OrganizationEvent);
await broadcastOrganizationSnapshot(c);
}
const profile = await c.db
@ -648,12 +487,12 @@ export const organizationActions = {
return await getOrganizationSummarySnapshot(c);
},
async reconcileWorkbenchState(c: any, input: OrganizationUseInput): Promise<OrganizationSummarySnapshot> {
async adminReconcileWorkspaceState(c: any, input: OrganizationUseInput): Promise<OrganizationSummarySnapshot> {
assertOrganization(c, input.organizationId);
return await reconcileWorkbenchProjection(c);
return await reconcileWorkspaceProjection(c);
},
async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string; sessionId?: string }> {
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,
@ -668,8 +507,8 @@ export const organizationActions = {
// The task workflow creates the session record and sends the message in
// the background. The client observes progress via push events on the
// task subscription topic.
const task = await requireWorkbenchTask(c, created.taskId);
await task.createWorkbenchSessionAndSend({
const task = await requireWorkspaceTask(c, input.repoId, created.taskId);
await task.createWorkspaceSessionAndSend({
model: input.model,
text: input.task,
});
@ -677,84 +516,79 @@ export const organizationActions = {
return { taskId: created.taskId };
},
async markWorkbenchUnread(c: any, input: TaskWorkbenchSelectInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.markWorkbenchUnread({});
async markWorkspaceUnread(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.markWorkspaceUnread({});
},
async renameWorkbenchTask(c: any, input: TaskWorkbenchRenameInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.renameWorkbenchTask(input);
async renameWorkspaceTask(c: any, input: TaskWorkspaceRenameInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.renameWorkspaceTask(input);
},
async renameWorkbenchBranch(c: any, input: TaskWorkbenchRenameInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.renameWorkbenchBranch(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 createWorkbenchSession(c: any, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }> {
const task = await requireWorkbenchTask(c, input.taskId);
return await task.createWorkbenchSession({ ...(input.model ? { model: input.model } : {}) });
async renameWorkspaceSession(c: any, input: TaskWorkspaceRenameSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.renameWorkspaceSession(input);
},
async renameWorkbenchSession(c: any, input: TaskWorkbenchRenameSessionInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.renameWorkbenchSession(input);
async setWorkspaceSessionUnread(c: any, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.setWorkspaceSessionUnread(input);
},
async setWorkbenchSessionUnread(c: any, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.setWorkbenchSessionUnread(input);
async updateWorkspaceDraft(c: any, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.updateWorkspaceDraft(input);
},
async updateWorkbenchDraft(c: any, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.updateWorkbenchDraft(input);
async changeWorkspaceModel(c: any, input: TaskWorkspaceChangeModelInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.changeWorkspaceModel(input);
},
async changeWorkbenchModel(c: any, input: TaskWorkbenchChangeModelInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.changeWorkbenchModel(input);
async sendWorkspaceMessage(c: any, input: TaskWorkspaceSendMessageInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.sendWorkspaceMessage(input);
},
async sendWorkbenchMessage(c: any, input: TaskWorkbenchSendMessageInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.sendWorkbenchMessage(input);
async stopWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.stopWorkspaceSession(input);
},
async stopWorkbenchSession(c: any, input: TaskWorkbenchSessionInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.stopWorkbenchSession(input);
async closeWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.closeWorkspaceSession(input);
},
async closeWorkbenchSession(c: any, input: TaskWorkbenchSessionInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.closeWorkbenchSession(input);
async publishWorkspacePr(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.publishWorkspacePr({});
},
async publishWorkbenchPr(c: any, input: TaskWorkbenchSelectInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.publishWorkbenchPr({});
async revertWorkspaceFile(c: any, input: TaskWorkspaceDiffInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.revertWorkspaceFile(input);
},
async revertWorkbenchFile(c: any, input: TaskWorkbenchDiffInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.revertWorkbenchFile(input);
async adminReloadGithubOrganization(c: any): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).adminReloadOrganization({});
},
async reloadGithubOrganization(c: any): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).reloadOrganization({});
async adminReloadGithubPullRequests(c: any): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).adminReloadAllPullRequests({});
},
async reloadGithubPullRequests(c: any): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).reloadAllPullRequests({});
},
async reloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
async adminReloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).reloadRepository(input);
},
async reloadGithubPullRequest(c: any, input: { repoId: string; prNumber: number }): Promise<void> {
async adminReloadGithubPullRequest(c: any, input: { repoId: string; prNumber: number }): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).reloadPullRequest(input);
},
@ -786,39 +620,39 @@ export const organizationActions = {
return await repository.getRepoOverview({});
},
async switchTask(c: any, taskId: string): Promise<SwitchResult> {
const repoId = await resolveRepoId(c, taskId);
const h = getTask(c, c.state.organizationId, repoId, taskId);
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,
taskId: input.taskId,
sandboxProviderId: record.sandboxProviderId,
switchTarget: switched.switchTarget,
};
},
async history(c: any, input: HistoryQueryInput): Promise<HistoryEvent[]> {
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: HistoryEvent[] = [];
const allEvents: AuditLogEvent[] = [];
for (const row of repoRows) {
try {
const hist = await getOrCreateHistory(c, c.state.organizationId, row.repoId);
const items = await hist.list({
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", "history lookup failed for repo", {
logActorWarning("organization", "audit log lookup failed for repo", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
@ -832,57 +666,49 @@ export const organizationActions = {
async getTask(c: any, input: GetTaskInput): Promise<TaskRecord> {
assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId);
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 await repository.getTaskEnriched({ taskId: input.taskId });
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 resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId);
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 resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId);
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 resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId);
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 resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId);
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 resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId);
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 resolveRepoId(c, input.taskId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId);
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 });
},
};

View file

@ -8,6 +8,7 @@ import type {
FoundryOrganizationMember,
FoundryUser,
UpdateFoundryOrganizationProfileInput,
WorkspaceModelId,
} from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateGithubData, getOrCreateOrganization, selfOrganization } from "../handles.js";
@ -98,7 +99,7 @@ const githubWebhookLogger = logger.child({
scope: "github-webhook",
});
const PROFILE_ROW_ID = "profile";
const PROFILE_ROW_ID = 1;
function roundDurationMs(start: number): number {
return Math.round((performance.now() - start) * 100) / 100;
@ -359,6 +360,7 @@ async function buildAppSnapshot(c: any, sessionId: string, allowOrganizationRepa
githubLogin: profile?.githubLogin ?? "",
roleLabel: profile?.roleLabel ?? "GitHub user",
eligibleOrganizationIds,
defaultModel: profile?.defaultModel ?? "claude-sonnet-4",
}
: null;
@ -685,7 +687,6 @@ async function buildOrganizationStateFromRow(c: any, row: any, startedAt: number
slug: row.slug,
primaryDomain: row.primaryDomain,
seatAccrualMode: "first_prompt",
defaultModel: row.defaultModel,
autoImportRepos: row.autoImportRepos === 1,
},
github: {
@ -1078,6 +1079,15 @@ export const organizationAppActions = {
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,
@ -1393,14 +1403,14 @@ export const organizationAppActions = {
"installation_event",
);
if (body.action === "deleted") {
await githubData.clearState({
await githubData.adminClearState({
connectedAccount: accountLogin,
installationStatus: "install_required",
installationId: null,
label: "GitHub App installation removed",
});
} else if (body.action === "created") {
await githubData.fullSync({
await githubData.adminFullSync({
connectedAccount: accountLogin,
installationStatus: "connected",
installationId: body.installation?.id ?? null,
@ -1409,14 +1419,14 @@ export const organizationAppActions = {
label: "Syncing GitHub data from installation webhook...",
});
} else if (body.action === "suspend") {
await githubData.clearState({
await githubData.adminClearState({
connectedAccount: accountLogin,
installationStatus: "reconnect_required",
installationId: body.installation?.id ?? null,
label: "GitHub App installation suspended",
});
} else if (body.action === "unsuspend") {
await githubData.fullSync({
await githubData.adminFullSync({
connectedAccount: accountLogin,
installationStatus: "connected",
installationId: body.installation?.id ?? null,
@ -1440,7 +1450,7 @@ export const organizationAppActions = {
},
"repository_membership_changed",
);
await githubData.fullSync({
await githubData.adminFullSync({
connectedAccount: accountLogin,
installationStatus: "connected",
installationId: body.installation?.id ?? null,
@ -1578,7 +1588,6 @@ export const organizationAppActions = {
displayName: input.displayName,
slug,
primaryDomain: existing?.primaryDomain ?? (input.kind === "personal" ? "personal" : `${slug}.github`),
defaultModel: existing?.defaultModel ?? "claude-sonnet-4",
autoImportRepos: existing?.autoImportRepos ?? 1,
repoImportStatus: existing?.repoImportStatus ?? "not_started",
githubConnectedAccount: input.githubLogin,

View file

@ -10,24 +10,6 @@ const journal = {
tag: "0000_melted_viper",
breakpoints: true,
},
{
idx: 1,
when: 1773638400000,
tag: "0001_auth_index_tables",
breakpoints: true,
},
{
idx: 2,
when: 1773720000000,
tag: "0002_task_summaries",
breakpoints: true,
},
{
idx: 3,
when: 1773810001000,
tag: "0003_drop_provider_profiles",
breakpoints: true,
},
],
} as const;
@ -73,7 +55,7 @@ CREATE TABLE \`organization_members\` (
);
--> statement-breakpoint
CREATE TABLE \`organization_profile\` (
\`id\` text PRIMARY KEY NOT NULL,
\`id\` integer PRIMARY KEY NOT NULL,
\`kind\` text NOT NULL,
\`github_account_id\` text NOT NULL,
\`github_login\` text NOT NULL,
@ -81,7 +63,6 @@ CREATE TABLE \`organization_profile\` (
\`display_name\` text NOT NULL,
\`slug\` text NOT NULL,
\`primary_domain\` text NOT NULL,
\`default_model\` text NOT NULL,
\`auto_import_repos\` integer NOT NULL,
\`repo_import_status\` text NOT NULL,
\`github_connected_account\` text NOT NULL,
@ -102,7 +83,8 @@ CREATE TABLE \`organization_profile\` (
\`billing_renewal_at\` text,
\`billing_payment_method_label\` text NOT NULL,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
\`updated_at\` integer NOT NULL,
CONSTRAINT \`organization_profile_singleton_id_check\` CHECK(\`id\` = 1)
);
--> statement-breakpoint
CREATE TABLE \`repos\` (
@ -122,56 +104,6 @@ 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
);
`,
m0001: `CREATE TABLE IF NOT EXISTS \`auth_session_index\` (
\`session_id\` text PRIMARY KEY NOT NULL,
\`session_token\` text NOT NULL,
\`user_id\` text NOT NULL,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS \`auth_email_index\` (
\`email\` text PRIMARY KEY NOT NULL,
\`user_id\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS \`auth_account_index\` (
\`id\` text PRIMARY KEY NOT NULL,
\`provider_id\` text NOT NULL,
\`account_id\` text NOT NULL,
\`user_id\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS \`auth_verification\` (
\`id\` text PRIMARY KEY NOT NULL,
\`identifier\` text NOT NULL,
\`value\` text NOT NULL,
\`expires_at\` integer NOT NULL,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0002: `CREATE TABLE IF NOT EXISTS \`task_summaries\` (
\`task_id\` text PRIMARY KEY NOT NULL,
\`repo_id\` text NOT NULL,
\`title\` text NOT NULL,
\`status\` text NOT NULL,
\`repo_name\` text NOT NULL,
\`updated_at_ms\` integer NOT NULL,
\`branch\` text,
\`pull_request_json\` text,
\`sessions_summary_json\` text DEFAULT '[]' NOT NULL
);
`,
m0003: `DROP TABLE IF EXISTS \`provider_profiles\`;
`,
} as const,
};

View file

@ -1,4 +1,5 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
import { check, integer, sqliteTable, text } from "rivetkit/db/drizzle";
import { sql } from "drizzle-orm";
// SQLite is per organization actor instance, so no organizationId column needed.
@ -14,66 +15,41 @@ export const repos = sqliteTable("repos", {
updatedAt: integer("updated_at").notNull(),
});
/**
* Coordinator index of TaskActor instances.
* Fast taskId repoId lookup so the organization can route requests
* to the correct RepositoryActor without scanning all repos.
*/
export const taskLookup = sqliteTable("task_lookup", {
taskId: text("task_id").notNull().primaryKey(),
repoId: text("repo_id").notNull(),
});
/**
* Coordinator index of TaskActor instances materialized sidebar projection.
* Task actors push summary updates to the organization actor via
* applyTaskSummaryUpdate(). Source of truth lives on each TaskActor;
* this table exists so organization reads stay local without fan-out.
*/
export const taskSummaries = sqliteTable("task_summaries", {
taskId: text("task_id").notNull().primaryKey(),
repoId: text("repo_id").notNull(),
title: text("title").notNull(),
status: text("status").notNull(),
repoName: text("repo_name").notNull(),
updatedAtMs: integer("updated_at_ms").notNull(),
branch: text("branch"),
pullRequestJson: text("pull_request_json"),
sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"),
});
export const organizationProfile = sqliteTable("organization_profile", {
id: text("id").notNull().primaryKey(),
kind: text("kind").notNull(),
githubAccountId: text("github_account_id").notNull(),
githubLogin: text("github_login").notNull(),
githubAccountType: text("github_account_type").notNull(),
displayName: text("display_name").notNull(),
slug: text("slug").notNull(),
primaryDomain: text("primary_domain").notNull(),
defaultModel: text("default_model").notNull(),
autoImportRepos: integer("auto_import_repos").notNull(),
repoImportStatus: text("repo_import_status").notNull(),
githubConnectedAccount: text("github_connected_account").notNull(),
githubInstallationStatus: text("github_installation_status").notNull(),
githubSyncStatus: text("github_sync_status").notNull(),
githubInstallationId: integer("github_installation_id"),
githubLastSyncLabel: text("github_last_sync_label").notNull(),
githubLastSyncAt: integer("github_last_sync_at"),
githubLastWebhookAt: integer("github_last_webhook_at"),
githubLastWebhookEvent: text("github_last_webhook_event"),
stripeCustomerId: text("stripe_customer_id"),
stripeSubscriptionId: text("stripe_subscription_id"),
stripePriceId: text("stripe_price_id"),
billingPlanId: text("billing_plan_id").notNull(),
billingStatus: text("billing_status").notNull(),
billingSeatsIncluded: integer("billing_seats_included").notNull(),
billingTrialEndsAt: text("billing_trial_ends_at"),
billingRenewalAt: text("billing_renewal_at"),
billingPaymentMethodLabel: text("billing_payment_method_label").notNull(),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const organizationProfile = sqliteTable(
"organization_profile",
{
id: integer("id").primaryKey(),
kind: text("kind").notNull(),
githubAccountId: text("github_account_id").notNull(),
githubLogin: text("github_login").notNull(),
githubAccountType: text("github_account_type").notNull(),
displayName: text("display_name").notNull(),
slug: text("slug").notNull(),
primaryDomain: text("primary_domain").notNull(),
autoImportRepos: integer("auto_import_repos").notNull(),
repoImportStatus: text("repo_import_status").notNull(),
githubConnectedAccount: text("github_connected_account").notNull(),
githubInstallationStatus: text("github_installation_status").notNull(),
githubSyncStatus: text("github_sync_status").notNull(),
githubInstallationId: integer("github_installation_id"),
githubLastSyncLabel: text("github_last_sync_label").notNull(),
githubLastSyncAt: integer("github_last_sync_at"),
githubLastWebhookAt: integer("github_last_webhook_at"),
githubLastWebhookEvent: text("github_last_webhook_event"),
stripeCustomerId: text("stripe_customer_id"),
stripeSubscriptionId: text("stripe_subscription_id"),
stripePriceId: text("stripe_price_id"),
billingPlanId: text("billing_plan_id").notNull(),
billingStatus: text("billing_status").notNull(),
billingSeatsIncluded: integer("billing_seats_included").notNull(),
billingTrialEndsAt: text("billing_trial_ends_at"),
billingRenewalAt: text("billing_renewal_at"),
billingPaymentMethodLabel: text("billing_payment_method_label").notNull(),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
},
(table) => [check("organization_profile_singleton_id_check", sql`${table.id} = 1`)],
);
export const organizationMembers = sqliteTable("organization_members", {
id: text("id").notNull().primaryKey(),
@ -133,6 +109,7 @@ export const authAccountIndex = sqliteTable("auth_account_index", {
updatedAt: integer("updated_at").notNull(),
});
/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */
export const authVerification = sqliteTable("auth_verification", {
id: text("id").notNull().primaryKey(),
identifier: text("identifier").notNull(),

View file

@ -2,12 +2,21 @@
import { randomUUID } from "node:crypto";
import { and, desc, eq, isNotNull, ne } from "drizzle-orm";
import { Loop } from "rivetkit/workflow";
import type { AgentType, RepoOverview, SandboxProviderId, TaskRecord, TaskSummary } from "@sandbox-agent/foundry-shared";
import { getGithubData, getOrCreateHistory, getOrCreateTask, getTask, selfRepository } from "../handles.js";
import type {
AgentType,
RepoOverview,
SandboxProviderId,
TaskRecord,
TaskSummary,
WorkspacePullRequestSummary,
WorkspaceSessionSummary,
WorkspaceTaskSummary,
} from "@sandbox-agent/foundry-shared";
import { getGithubData, getOrCreateAuditLog, getOrCreateOrganization, getOrCreateTask, getTask, selfRepository } from "../handles.js";
import { deriveFallbackTitle, resolveCreateFlowDecision } from "../../services/create-flow.js";
import { expectQueueResponse } from "../../services/queue.js";
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
import { repoMeta, taskIndex } from "./db/schema.js";
import { repoMeta, taskIndex, tasks } from "./db/schema.js";
interface CreateTaskCommand {
task: string;
@ -29,10 +38,6 @@ interface ListTaskSummariesCommand {
includeArchived?: boolean;
}
interface GetTaskEnrichedCommand {
taskId: string;
}
interface GetPullRequestForBranchCommand {
branchName: string;
}
@ -52,6 +57,61 @@ function isStaleTaskReferenceError(error: unknown): boolean {
return isActorNotFoundError(error) || message.startsWith("Task not found:");
}
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),
};
}
function taskSummaryFromRow(c: any, row: any): WorkspaceTaskSummary {
return {
id: row.taskId,
repoId: c.state.repoId,
title: row.title,
status: row.status,
repoName: row.repoName,
updatedAtMs: row.updatedAtMs,
branch: row.branch ?? null,
pullRequest: parseJsonValue<WorkspacePullRequestSummary | null>(row.pullRequestJson, null),
sessionsSummary: parseJsonValue<WorkspaceSessionSummary[]>(row.sessionsSummaryJson, []),
};
}
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 notifyOrganizationSnapshotChanged(c: any): Promise<void> {
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.refreshOrganizationSnapshot({});
}
async function persistRemoteUrl(c: any, remoteUrl: string): Promise<void> {
c.state.remoteUrl = remoteUrl;
await c.db
@ -104,6 +164,46 @@ 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,
repoId: taskSummary.repoId,
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),
};
}
function workspaceTaskSummaryFromRow(row: any): WorkspaceTaskSummary {
return {
id: row.taskId,
repoId: row.repoId,
title: row.title,
status: row.status,
repoName: row.repoName,
updatedAtMs: row.updatedAtMs,
branch: row.branch ?? null,
pullRequest: parseJsonValue(row.pullRequestJson, null),
sessionsSummary: parseJsonValue<WorkspaceSessionSummary[]>(row.sessionsSummaryJson, []),
};
}
async function resolveGitHubRepository(c: any) {
const githubData = getGithubData(c, c.state.organizationId);
return await githubData.getRepository({ repoId: c.state.repoId }).catch(() => null);
@ -114,34 +214,6 @@ async function listGitHubBranches(c: any): Promise<Array<{ branchName: string; c
return await githubData.listBranchesForRepository({ repoId: c.state.repoId }).catch(() => []);
}
async function enrichTaskRecord(c: any, record: TaskRecord): Promise<TaskRecord> {
const branchName = record.branchName?.trim() || null;
if (!branchName) {
return record;
}
const pr =
branchName != null
? await getGithubData(c, c.state.organizationId)
.listPullRequestsForRepository({ repoId: c.state.repoId })
.then((rows: any[]) => rows.find((row) => row.headRefName === branchName) ?? null)
.catch(() => null)
: null;
return {
...record,
prUrl: pr?.url ?? null,
prAuthor: pr?.authorLogin ?? null,
ciStatus: null,
reviewStatus: null,
reviewer: pr?.authorLogin ?? null,
diffStat: record.diffStat ?? null,
hasUnpushed: record.hasUnpushed ?? null,
conflictsWithMain: record.conflictsWithMain ?? null,
parentBranch: record.parentBranch ?? null,
};
}
async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
const organizationId = c.state.organizationId;
const repoId = c.state.repoId;
@ -213,19 +285,60 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
const created = await taskHandle.initialize({ sandboxProviderId: cmd.sandboxProviderId });
const history = await getOrCreateHistory(c, organizationId, repoId);
await history.append({
kind: "task.created",
taskId,
payload: {
try {
await upsertTaskSummary(c, await taskHandle.getTaskSummary({}));
await notifyOrganizationSnapshotChanged(c);
} catch (error) {
logActorWarning("repository", "failed seeding task summary after task creation", {
organizationId,
repoId,
sandboxProviderId: cmd.sandboxProviderId,
taskId,
error: resolveErrorMessage(error),
});
}
const auditLog = await getOrCreateAuditLog(c, organizationId, repoId);
await auditLog.send(
"auditLog.command.append",
{
kind: "task.created",
taskId,
payload: {
repoId,
sandboxProviderId: cmd.sandboxProviderId,
},
},
});
{
wait: false,
},
);
try {
const taskSummary = await taskHandle.getTaskSummary({});
await upsertTaskSummary(c, taskSummary);
} catch (error) {
logActorWarning("repository", "failed seeding repository task projection", {
organizationId,
repoId,
taskId,
error: resolveErrorMessage(error),
});
}
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 }> {
const branchName = cmd.branchName.trim();
if (!branchName) {
@ -289,40 +402,23 @@ async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand
}
async function listTaskSummaries(c: any, includeArchived = false): Promise<TaskSummary[]> {
const taskRows = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).orderBy(desc(taskIndex.updatedAt)).all();
const records: TaskSummary[] = [];
const rows = await c.db.select().from(tasks).orderBy(desc(tasks.updatedAtMs)).all();
return rows
.map((row) => ({
organizationId: c.state.organizationId,
repoId: c.state.repoId,
taskId: row.taskId,
branchName: row.branch ?? null,
title: row.title,
status: row.status,
updatedAt: row.updatedAtMs,
}))
.filter((row) => includeArchived || row.status !== "archived");
}
for (const row of taskRows) {
try {
const record = await getTask(c, c.state.organizationId, c.state.repoId, row.taskId).get();
if (!includeArchived && record.status === "archived") {
continue;
}
records.push({
organizationId: record.organizationId,
repoId: record.repoId,
taskId: record.taskId,
branchName: record.branchName,
title: record.title,
status: record.status,
updatedAt: record.updatedAt,
});
} catch (error) {
if (isStaleTaskReferenceError(error)) {
await deleteStaleTaskIndexRow(c, row.taskId);
continue;
}
logActorWarning("repository", "failed loading task summary row", {
organizationId: c.state.organizationId,
repoId: c.state.repoId,
taskId: row.taskId,
error: resolveErrorMessage(error),
});
}
}
records.sort((a, b) => b.updatedAt - a.updatedAt);
return records;
async function listWorkspaceTaskSummaries(c: any): Promise<WorkspaceTaskSummary[]> {
const rows = await c.db.select().from(tasks).orderBy(desc(tasks.updatedAtMs)).all();
return rows.map(workspaceTaskSummaryFromRow);
}
function sortOverviewBranches(
@ -415,38 +511,12 @@ export const repositoryActions = {
return await listKnownTaskBranches(c);
},
async registerTaskBranch(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> {
const self = selfRepository(c);
return expectQueueResponse<{ branchName: string; headSha: string }>(
await self.send(repositoryWorkflowQueueName("repository.command.registerTaskBranch"), cmd, {
wait: true,
timeout: 10_000,
}),
);
},
async listTaskSummaries(c: any, cmd?: ListTaskSummariesCommand): Promise<TaskSummary[]> {
return await listTaskSummaries(c, cmd?.includeArchived === true);
},
async getTaskEnriched(c: any, cmd: GetTaskEnrichedCommand): Promise<TaskRecord> {
const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.taskId, cmd.taskId)).get();
if (!row) {
const record = await getTask(c, c.state.organizationId, c.state.repoId, cmd.taskId).get();
await reinsertTaskIndexRow(c, cmd.taskId, record.branchName ?? null, record.updatedAt ?? Date.now());
return await enrichTaskRecord(c, record);
}
try {
const record = await getTask(c, c.state.organizationId, c.state.repoId, cmd.taskId).get();
return await enrichTaskRecord(c, record);
} catch (error) {
if (isStaleTaskReferenceError(error)) {
await deleteStaleTaskIndexRow(c, cmd.taskId);
throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`);
}
throw error;
}
async listWorkspaceTaskSummaries(c: any): Promise<WorkspaceTaskSummary[]> {
return await listWorkspaceTaskSummaries(c);
},
async getRepositoryMetadata(c: any): Promise<{ defaultBranch: string | null; fullName: string | null; remoteUrl: string }> {
@ -468,34 +538,23 @@ export const repositoryActions = {
const prRows = await githubData.listPullRequestsForRepository({ repoId: c.state.repoId }).catch(() => []);
const prByBranch = new Map(prRows.map((row) => [row.headRefName, row]));
const taskRows = await c.db
.select({
taskId: taskIndex.taskId,
branchName: taskIndex.branchName,
updatedAt: taskIndex.updatedAt,
})
.from(taskIndex)
.all();
const taskRows = await c.db.select().from(tasks).all();
const taskMetaByBranch = new Map<string, { taskId: string; title: string | null; status: TaskRecord["status"] | null; updatedAt: number }>();
const taskMetaByBranch = new Map<
string,
{ taskId: string; title: string | null; status: TaskRecord["status"] | null; updatedAt: number; pullRequest: WorkspacePullRequestSummary | null }
>();
for (const row of taskRows) {
if (!row.branchName) {
if (!row.branch) {
continue;
}
try {
const record = await getTask(c, c.state.organizationId, c.state.repoId, row.taskId).get();
taskMetaByBranch.set(row.branchName, {
taskId: row.taskId,
title: record.title ?? null,
status: record.status,
updatedAt: record.updatedAt,
});
} catch (error) {
if (isStaleTaskReferenceError(error)) {
await deleteStaleTaskIndexRow(c, row.taskId);
continue;
}
}
taskMetaByBranch.set(row.branch, {
taskId: row.taskId,
title: row.title ?? null,
status: row.status,
updatedAt: row.updatedAtMs,
pullRequest: parseJsonValue<WorkspacePullRequestSummary | null>(row.pullRequestJson, null),
});
}
const branchMap = new Map<string, { branchName: string; commitSha: string }>();
@ -514,7 +573,7 @@ export const repositoryActions = {
const branches = sortOverviewBranches(
[...branchMap.values()].map((branch) => {
const taskMeta = taskMetaByBranch.get(branch.branchName);
const pr = prByBranch.get(branch.branchName);
const pr = taskMeta?.pullRequest ?? prByBranch.get(branch.branchName) ?? null;
return {
branchName: branch.branchName,
commitSha: branch.commitSha,
@ -522,10 +581,10 @@ export const repositoryActions = {
taskTitle: taskMeta?.title ?? null,
taskStatus: taskMeta?.status ?? null,
prNumber: pr?.number ?? null,
prState: pr?.state ?? null,
prUrl: pr?.url ?? null,
prState: "state" in (pr ?? {}) ? pr.state : null,
prUrl: "url" in (pr ?? {}) ? pr.url : null,
ciStatus: null,
reviewStatus: 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),
};
@ -543,15 +602,51 @@ export const repositoryActions = {
};
},
async getPullRequestForBranch(c: any, cmd: GetPullRequestForBranchCommand): Promise<{ number: number; status: "draft" | "ready" } | null> {
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 findTaskForGithubBranch(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 refreshTaskSummaryForGithubBranch(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) {
return null;
}
const githubData = getGithubData(c, c.state.organizationId);
return await githubData.getPullRequestForBranch({
const rows = await githubData.listPullRequestsForRepository({
repoId: c.state.repoId,
branchName,
});
return rows.find((candidate: WorkspacePullRequestSummary) => candidate.headRefName === branchName) ?? null;
},
};

View file

@ -10,12 +10,6 @@ const journal = {
tag: "0000_useful_la_nuit",
breakpoints: true,
},
{
idx: 1,
when: 1778900000000,
tag: "0001_remove_local_git_state",
breakpoints: true,
},
],
} as const;
@ -23,21 +17,30 @@ export default {
journal,
migrations: {
m0000: `CREATE TABLE \`repo_meta\` (
\t\`id\` integer PRIMARY KEY NOT NULL,
\t\`remote_url\` text NOT NULL,
\t\`updated_at\` integer NOT NULL
\`id\` integer PRIMARY KEY NOT NULL,
\`remote_url\` text NOT NULL,
\`updated_at\` integer NOT NULL,
CONSTRAINT \`repo_meta_singleton_id_check\` CHECK(\`id\` = 1)
);
--> statement-breakpoint
CREATE TABLE \`task_index\` (
\t\`task_id\` text PRIMARY KEY NOT NULL,
\t\`branch_name\` text,
\t\`created_at\` integer NOT NULL,
\t\`updated_at\` integer NOT NULL
\`task_id\` text PRIMARY KEY NOT NULL,
\`branch_name\` text,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0001: `DROP TABLE IF EXISTS \`branches\`;
--> statement-breakpoint
DROP TABLE IF EXISTS \`repo_action_jobs\`;
CREATE TABLE \`tasks\` (
\`task_id\` text PRIMARY KEY NOT NULL,
\`repo_id\` text NOT NULL,
\`title\` text NOT NULL,
\`status\` text NOT NULL,
\`repo_name\` text NOT NULL,
\`updated_at_ms\` integer NOT NULL,
\`branch\` text,
\`pull_request_json\` text,
\`sessions_summary_json\` text DEFAULT '[]' NOT NULL
);
`,
} as const,
};

View file

@ -1,19 +1,23 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
import { check, integer, sqliteTable, text } from "rivetkit/db/drizzle";
import { sql } from "drizzle-orm";
// SQLite is per repository actor instance (organizationId+repoId).
export const repoMeta = sqliteTable("repo_meta", {
id: integer("id").primaryKey(),
remoteUrl: text("remote_url").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const repoMeta = sqliteTable(
"repo_meta",
{
id: integer("id").primaryKey(),
remoteUrl: text("remote_url").notNull(),
updatedAt: integer("updated_at").notNull(),
},
(table) => [check("repo_meta_singleton_id_check", sql`${table.id} = 1`)],
);
/**
* Coordinator index of TaskActor instances.
* The repository actor is the coordinator for tasks. Each row maps a
* taskId to its branch name. Used for branch conflict checking and
* task-by-branch lookups. Rows are inserted at task creation and
* updated on branch rename.
* taskId to its immutable branch name. Used for branch conflict checking
* and task-by-branch lookups. Rows are inserted at task creation.
*/
export const taskIndex = sqliteTable("task_index", {
taskId: text("task_id").notNull().primaryKey(),
@ -21,3 +25,35 @@ export const taskIndex = sqliteTable("task_index", {
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
/**
* Repository-owned materialized task summary projection.
* Task actors push summary updates to their direct repository coordinator,
* which keeps this table local for fast list/lookups without fan-out.
*/
export const tasks = sqliteTable("tasks", {
taskId: text("task_id").notNull().primaryKey(),
title: text("title").notNull(),
status: text("status").notNull(),
repoName: text("repo_name").notNull(),
updatedAtMs: integer("updated_at_ms").notNull(),
branch: text("branch"),
pullRequestJson: text("pull_request_json"),
sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"),
});
/**
* Materialized task summary projection owned by the repository coordinator.
* Task actors push updates here; organization reads fan in through repositories.
*/
export const tasks = sqliteTable("tasks", {
taskId: text("task_id").notNull().primaryKey(),
repoId: text("repo_id").notNull(),
title: text("title").notNull(),
status: text("status").notNull(),
repoName: text("repo_name").notNull(),
updatedAtMs: integer("updated_at_ms").notNull(),
branch: text("branch"),
pullRequestJson: text("pull_request_json"),
sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"),
});

View file

@ -3,7 +3,7 @@ CREATE TABLE `task` (
`branch_name` text,
`title` text,
`task` text NOT NULL,
`provider_id` text NOT NULL,
`sandbox_provider_id` text NOT NULL,
`status` text NOT NULL,
`agent_type` text DEFAULT 'claude',
`pr_submitted` integer DEFAULT 0,
@ -19,13 +19,17 @@ CREATE TABLE `task_runtime` (
`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)
);
--> statement-breakpoint
CREATE TABLE `task_sandboxes` (
`sandbox_id` text PRIMARY KEY NOT NULL,
`provider_id` text NOT NULL,
`sandbox_provider_id` text NOT NULL,
`sandbox_actor_id` text,
`switch_target` text NOT NULL,
`cwd` text,
@ -34,10 +38,15 @@ CREATE TABLE `task_sandboxes` (
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `task_workbench_sessions` (
CREATE TABLE `task_workspace_sessions` (
`session_id` text PRIMARY KEY NOT NULL,
`sandbox_session_id` text,
`session_name` text NOT NULL,
`model` text NOT NULL,
`status` text DEFAULT 'ready' NOT NULL,
`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,

View file

@ -221,8 +221,8 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_workbench_sessions": {
"name": "task_workbench_sessions",
"task_workspace_sessions": {
"name": "task_workspace_sessions",
"columns": {
"session_id": {
"name": "session_id",

View file

@ -10,12 +10,6 @@ const journal = {
tag: "0000_charming_maestro",
breakpoints: true,
},
{
idx: 1,
when: 1773810000000,
tag: "0001_sandbox_provider_columns",
breakpoints: true,
},
],
} as const;
@ -27,10 +21,8 @@ export default {
\`branch_name\` text,
\`title\` text,
\`task\` text NOT NULL,
\`provider_id\` text NOT NULL,
\`sandbox_provider_id\` text NOT NULL,
\`status\` text NOT NULL,
\`agent_type\` text DEFAULT 'claude',
\`pr_submitted\` integer DEFAULT 0,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL,
CONSTRAINT "task_singleton_id_check" CHECK("task"."id" = 1)
@ -39,17 +31,17 @@ export default {
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,
\`updated_at\` integer NOT NULL,
CONSTRAINT "task_runtime_singleton_id_check" CHECK("task_runtime"."id" = 1)
);
--> statement-breakpoint
CREATE TABLE \`task_sandboxes\` (
\`sandbox_id\` text PRIMARY KEY NOT NULL,
\`provider_id\` text NOT NULL,
\`sandbox_provider_id\` text NOT NULL,
\`sandbox_actor_id\` text,
\`switch_target\` text NOT NULL,
\`cwd\` text,
@ -58,24 +50,21 @@ CREATE TABLE \`task_sandboxes\` (
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE \`task_workbench_sessions\` (
CREATE TABLE \`task_workspace_sessions\` (
\`session_id\` text PRIMARY KEY NOT NULL,
\`sandbox_session_id\` text,
\`session_name\` text NOT NULL,
\`model\` text NOT NULL,
\`unread\` integer DEFAULT 0 NOT NULL,
\`draft_text\` text DEFAULT '' NOT NULL,
\`draft_attachments_json\` text DEFAULT '[]' NOT NULL,
\`draft_updated_at\` integer,
\`status\` text DEFAULT 'ready' NOT NULL,
\`error_message\` text,
\`transcript_json\` text DEFAULT '[]' NOT NULL,
\`transcript_updated_at\` integer,
\`created\` integer DEFAULT 1 NOT NULL,
\`closed\` integer DEFAULT 0 NOT NULL,
\`thinking_since_ms\` integer,
\`created_at\` integer NOT NULL,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0001: `ALTER TABLE \`task\` RENAME COLUMN \`provider_id\` TO \`sandbox_provider_id\`;
--> statement-breakpoint
ALTER TABLE \`task_sandboxes\` RENAME COLUMN \`provider_id\` TO \`sandbox_provider_id\`;
`,
} as const,
};

View file

@ -11,8 +11,6 @@ export const task = sqliteTable(
task: text("task").notNull(),
sandboxProviderId: text("sandbox_provider_id").notNull(),
status: text("status").notNull(),
agentType: text("agent_type").default("claude"),
prSubmitted: integer("pr_submitted").default(0),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
},
@ -24,14 +22,10 @@ export const taskRuntime = sqliteTable(
{
id: integer("id").primaryKey(),
activeSandboxId: text("active_sandbox_id"),
activeSessionId: text("active_session_id"),
activeSwitchTarget: text("active_switch_target"),
activeCwd: text("active_cwd"),
statusMessage: text("status_message"),
gitStateJson: text("git_state_json"),
gitStateUpdatedAt: integer("git_state_updated_at"),
provisionStage: text("provision_stage"),
provisionStageUpdatedAt: integer("provision_stage_updated_at"),
updatedAt: integer("updated_at").notNull(),
},
(table) => [check("task_runtime_singleton_id_check", sql`${table.id} = 1`)],
@ -54,12 +48,12 @@ export const taskSandboxes = sqliteTable("task_sandboxes", {
});
/**
* Coordinator index of workbench sessions within this task.
* Coordinator index of workspace sessions within this task.
* The task actor is the coordinator for sessions. Each row holds session
* metadata, model, status, transcript, and draft state. Sessions are
* sub-entities of the task no separate session actor in the DB.
*/
export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", {
export const taskWorkspaceSessions = sqliteTable("task_workspace_sessions", {
sessionId: text("session_id").notNull().primaryKey(),
sandboxSessionId: text("sandbox_session_id"),
sessionName: text("session_name").notNull(),
@ -68,11 +62,6 @@ export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", {
errorMessage: text("error_message"),
transcriptJson: text("transcript_json").notNull().default("[]"),
transcriptUpdatedAt: integer("transcript_updated_at"),
unread: integer("unread").notNull().default(0),
draftText: text("draft_text").notNull().default(""),
// Structured by the workbench composer attachment payload format.
draftAttachmentsJson: text("draft_attachments_json").notNull().default("[]"),
draftUpdatedAt: integer("draft_updated_at"),
created: integer("created").notNull().default(1),
closed: integer("closed").notNull().default(0),
thinkingSinceMs: integer("thinking_since_ms"),

View file

@ -1,14 +1,13 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type {
AgentType,
TaskRecord,
TaskWorkbenchChangeModelInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchUpdateDraftInput,
TaskWorkspaceChangeModelInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceUpdateDraftInput,
SandboxProviderId,
} from "@sandbox-agent/foundry-shared";
import { expectQueueResponse } from "../../services/queue.js";
@ -16,24 +15,23 @@ import { selfTask } from "../handles.js";
import { taskDb } from "./db/db.js";
import { getCurrentRecord } from "./workflow/common.js";
import {
changeWorkbenchModel,
closeWorkbenchSession,
createWorkbenchSession,
changeWorkspaceModel,
closeWorkspaceSession,
createWorkspaceSession,
getSessionDetail,
getTaskDetail,
getTaskSummary,
markWorkbenchUnread,
publishWorkbenchPr,
renameWorkbenchBranch,
renameWorkbenchTask,
renameWorkbenchSession,
revertWorkbenchFile,
sendWorkbenchMessage,
syncWorkbenchSessionStatus,
setWorkbenchSessionUnread,
stopWorkbenchSession,
updateWorkbenchDraft,
} from "./workbench.js";
markWorkspaceUnread,
publishWorkspacePr,
renameWorkspaceTask,
renameWorkspaceSession,
revertWorkspaceFile,
sendWorkspaceMessage,
syncWorkspaceSessionStatus,
setWorkspaceSessionUnread,
stopWorkspaceSession,
updateWorkspaceDraft,
} from "./workspace.js";
import { TASK_QUEUE_NAMES, taskWorkflowQueueName, runTaskWorkflow } from "./workflow/index.js";
export interface TaskInput {
@ -45,10 +43,8 @@ export interface TaskInput {
title: string | null;
task: string;
sandboxProviderId: SandboxProviderId;
agentType: AgentType | null;
explicitTitle: string | null;
explicitBranchName: string | null;
initialPrompt: string | null;
}
interface InitializeCommand {
@ -69,48 +65,57 @@ interface TaskStatusSyncCommand {
at: number;
}
interface TaskWorkbenchValueCommand {
interface TaskWorkspaceValueCommand {
value: string;
authSessionId?: string;
}
interface TaskWorkbenchSessionTitleCommand {
interface TaskWorkspaceSessionTitleCommand {
sessionId: string;
title: string;
authSessionId?: string;
}
interface TaskWorkbenchSessionUnreadCommand {
interface TaskWorkspaceSessionUnreadCommand {
sessionId: string;
unread: boolean;
authSessionId?: string;
}
interface TaskWorkbenchUpdateDraftCommand {
interface TaskWorkspaceUpdateDraftCommand {
sessionId: string;
text: string;
attachments: Array<any>;
authSessionId?: string;
}
interface TaskWorkbenchChangeModelCommand {
interface TaskWorkspaceChangeModelCommand {
sessionId: string;
model: string;
authSessionId?: string;
}
interface TaskWorkbenchSendMessageCommand {
interface TaskWorkspaceSendMessageCommand {
sessionId: string;
text: string;
attachments: Array<any>;
authSessionId?: string;
}
interface TaskWorkbenchCreateSessionCommand {
interface TaskWorkspaceCreateSessionCommand {
model?: string;
authSessionId?: string;
}
interface TaskWorkbenchCreateSessionAndSendCommand {
interface TaskWorkspaceCreateSessionAndSendCommand {
model?: string;
text: string;
authSessionId?: string;
}
interface TaskWorkbenchSessionCommand {
interface TaskWorkspaceSessionCommand {
sessionId: string;
authSessionId?: string;
}
export const task = actor({
@ -126,16 +131,6 @@ export const task = actor({
repoId: input.repoId,
taskId: input.taskId,
repoRemote: input.repoRemote,
branchName: input.branchName,
title: input.title,
task: input.task,
sandboxProviderId: input.sandboxProviderId,
agentType: input.agentType,
explicitTitle: input.explicitTitle,
explicitBranchName: input.explicitBranchName,
initialPrompt: input.initialPrompt,
initialized: false,
previousStatus: null as string | null,
}),
actions: {
async initialize(c, cmd: InitializeCommand): Promise<TaskRecord> {
@ -220,19 +215,19 @@ export const task = actor({
return await getTaskSummary(c);
},
async getTaskDetail(c) {
return await getTaskDetail(c);
async getTaskDetail(c, input?: { authSessionId?: string }) {
return await getTaskDetail(c, input?.authSessionId);
},
async getSessionDetail(c, input: { sessionId: string }) {
return await getSessionDetail(c, input.sessionId);
async getSessionDetail(c, input: { sessionId: string; authSessionId?: string }) {
return await getSessionDetail(c, input.sessionId, input.authSessionId);
},
async markWorkbenchUnread(c): Promise<void> {
async markWorkspaceUnread(c, input?: { authSessionId?: string }): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.mark_unread"),
{},
taskWorkflowQueueName("task.command.workspace.mark_unread"),
{ authSessionId: input?.authSessionId },
{
wait: true,
timeout: 10_000,
@ -240,26 +235,26 @@ export const task = actor({
);
},
async renameWorkbenchTask(c, input: TaskWorkbenchRenameInput): Promise<void> {
async renameWorkspaceTask(c, input: TaskWorkspaceRenameInput): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.rename_task"), { value: input.value } satisfies TaskWorkbenchValueCommand, {
wait: true,
timeout: 20_000,
});
await self.send(
taskWorkflowQueueName("task.command.workspace.rename_task"),
{ value: input.value, authSessionId: input.authSessionId } satisfies TaskWorkspaceValueCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async renameWorkbenchBranch(c, input: TaskWorkbenchRenameInput): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.rename_branch"), { value: input.value } satisfies TaskWorkbenchValueCommand, {
wait: false,
});
},
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ sessionId: string }> {
async createWorkspaceSession(c, input?: { model?: string; authSessionId?: string }): Promise<{ sessionId: string }> {
const self = selfTask(c);
const result = await self.send(
taskWorkflowQueueName("task.command.workbench.create_session"),
{ ...(input?.model ? { model: input.model } : {}) } satisfies TaskWorkbenchCreateSessionCommand,
taskWorkflowQueueName("task.command.workspace.create_session"),
{
...(input?.model ? { model: input.model } : {}),
...(input?.authSessionId ? { authSessionId: input.authSessionId } : {}),
} satisfies TaskWorkspaceCreateSessionCommand,
{
wait: true,
timeout: 10_000,
@ -269,23 +264,23 @@ export const task = actor({
},
/**
* Fire-and-forget: creates a workbench session and sends the initial message.
* Used by createWorkbenchTask so the caller doesn't block on session creation.
* Fire-and-forget: creates a session and sends the initial message.
* Used by createWorkspaceTask so the caller doesn't block on session creation.
*/
async createWorkbenchSessionAndSend(c, input: { model?: string; text: string }): Promise<void> {
async createWorkspaceSessionAndSend(c, input: { model?: string; text: string; authSessionId?: string }): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.create_session_and_send"),
{ model: input.model, text: input.text } satisfies TaskWorkbenchCreateSessionAndSendCommand,
taskWorkflowQueueName("task.command.workspace.create_session_and_send"),
{ model: input.model, text: input.text, authSessionId: input.authSessionId } satisfies TaskWorkspaceCreateSessionAndSendCommand,
{ wait: false },
);
},
async renameWorkbenchSession(c, input: TaskWorkbenchRenameSessionInput): Promise<void> {
async renameWorkspaceSession(c, input: TaskWorkspaceRenameSessionInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.rename_session"),
{ sessionId: input.sessionId, title: input.title } satisfies TaskWorkbenchSessionTitleCommand,
taskWorkflowQueueName("task.command.workspace.rename_session"),
{ sessionId: input.sessionId, title: input.title, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionTitleCommand,
{
wait: true,
timeout: 10_000,
@ -293,11 +288,11 @@ export const task = actor({
);
},
async setWorkbenchSessionUnread(c, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
async setWorkspaceSessionUnread(c, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.set_session_unread"),
{ sessionId: input.sessionId, unread: input.unread } satisfies TaskWorkbenchSessionUnreadCommand,
taskWorkflowQueueName("task.command.workspace.set_session_unread"),
{ sessionId: input.sessionId, unread: input.unread, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionUnreadCommand,
{
wait: true,
timeout: 10_000,
@ -305,26 +300,27 @@ export const task = actor({
);
},
async updateWorkbenchDraft(c, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
async updateWorkspaceDraft(c, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.update_draft"),
taskWorkflowQueueName("task.command.workspace.update_draft"),
{
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
} satisfies TaskWorkbenchUpdateDraftCommand,
authSessionId: input.authSessionId,
} satisfies TaskWorkspaceUpdateDraftCommand,
{
wait: false,
},
);
},
async changeWorkbenchModel(c, input: TaskWorkbenchChangeModelInput): Promise<void> {
async changeWorkspaceModel(c, input: TaskWorkspaceChangeModelInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.change_model"),
{ sessionId: input.sessionId, model: input.model } satisfies TaskWorkbenchChangeModelCommand,
taskWorkflowQueueName("task.command.workspace.change_model"),
{ sessionId: input.sessionId, model: input.model, authSessionId: input.authSessionId } satisfies TaskWorkspaceChangeModelCommand,
{
wait: true,
timeout: 10_000,
@ -332,47 +328,56 @@ export const task = actor({
);
},
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageInput): Promise<void> {
async sendWorkspaceMessage(c, input: TaskWorkspaceSendMessageInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.send_message"),
taskWorkflowQueueName("task.command.workspace.send_message"),
{
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
} satisfies TaskWorkbenchSendMessageCommand,
authSessionId: input.authSessionId,
} satisfies TaskWorkspaceSendMessageCommand,
{
wait: false,
},
);
},
async stopWorkbenchSession(c, input: TaskSessionCommand): Promise<void> {
async stopWorkspaceSession(c, input: TaskSessionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.sessionId } satisfies TaskWorkbenchSessionCommand, {
wait: false,
});
await self.send(
taskWorkflowQueueName("task.command.workspace.stop_session"),
{ sessionId: input.sessionId, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionCommand,
{
wait: false,
},
);
},
async syncWorkbenchSessionStatus(c, input: TaskStatusSyncCommand): Promise<void> {
async syncWorkspaceSessionStatus(c, input: TaskStatusSyncCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.sync_session_status"), input, {
await self.send(taskWorkflowQueueName("task.command.workspace.sync_session_status"), input, {
wait: true,
timeout: 20_000,
});
},
async closeWorkbenchSession(c, input: TaskSessionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.sessionId } satisfies TaskWorkbenchSessionCommand, {
wait: false,
});
},
async publishWorkbenchPr(c): Promise<void> {
async closeWorkspaceSession(c, input: TaskSessionCommand): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.publish_pr"),
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,
@ -380,9 +385,9 @@ export const task = actor({
);
},
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
async revertWorkspaceFile(c, input: { path: string }): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, {
await self.send(taskWorkflowQueueName("task.command.workspace.revert_file"), input, {
wait: false,
});
},

View file

@ -2,8 +2,8 @@
import { eq } from "drizzle-orm";
import { getTaskSandbox } from "../../handles.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { task as taskTable, taskRuntime } from "../db/schema.js";
import { TASK_ROW_ID, appendHistory, getCurrentRecord, setTaskState } from "./common.js";
import { task as taskTable } from "../db/schema.js";
import { TASK_ROW_ID, appendAuditLog, getCurrentRecord, setTaskState } from "./common.js";
import { pushActiveBranchActivity } from "./push.js";
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
@ -25,6 +25,7 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void> {
const record = await getCurrentRecord(loopCtx);
let target = record.sandboxes.find((sandbox: any) => sandbox.sandboxId === record.activeSandboxId)?.switchTarget ?? "";
const sessionId = msg.body?.sessionId ?? null;
if (record.activeSandboxId) {
try {
@ -38,14 +39,14 @@ export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void
}
}
await appendHistory(loopCtx, "task.attach", {
await appendAuditLog(loopCtx, "task.attach", {
target,
sessionId: record.activeSessionId,
sessionId,
});
await msg.complete({
target,
sessionId: record.activeSessionId,
sessionId,
});
}
@ -64,20 +65,17 @@ 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> {
const db = loopCtx.db;
await db.update(taskRuntime).set({ statusMessage, updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run();
await appendHistory(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
export async function handleSimpleCommandActivity(loopCtx: any, msg: any, _statusMessage: string, historyKind: string): Promise<void> {
await appendAuditLog(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true });
}
export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<void> {
await setTaskState(loopCtx, "archive_stop_status_sync", "stopping status sync");
await setTaskState(loopCtx, "archive_stop_status_sync");
const record = await getCurrentRecord(loopCtx);
if (record.activeSandboxId) {
await setTaskState(loopCtx, "archive_release_sandbox", "releasing sandbox");
await setTaskState(loopCtx, "archive_release_sandbox");
void withTimeout(getTaskSandbox(loopCtx, loopCtx.state.organizationId, record.activeSandboxId).destroy(), 45_000, "sandbox destroy").catch((error) => {
logActorWarning("task.commands", "failed to release sandbox during archive", {
organizationId: loopCtx.state.organizationId,
@ -90,17 +88,15 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
}
const db = loopCtx.db;
await setTaskState(loopCtx, "archive_finalize", "finalizing archive");
await setTaskState(loopCtx, "archive_finalize");
await db.update(taskTable).set({ status: "archived", updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run();
await db.update(taskRuntime).set({ activeSessionId: null, statusMessage: "archived", updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run();
await appendHistory(loopCtx, "task.archive", { reason: msg.body?.reason ?? null });
await appendAuditLog(loopCtx, "task.archive", { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true });
}
export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
await setTaskState(loopCtx, "kill_destroy_sandbox", "destroying sandbox");
await setTaskState(loopCtx, "kill_destroy_sandbox");
const record = await getCurrentRecord(loopCtx);
if (!record.activeSandboxId) {
return;
@ -110,13 +106,11 @@ export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
}
export async function killWriteDbActivity(loopCtx: any, msg: any): Promise<void> {
await setTaskState(loopCtx, "kill_finalize", "finalizing kill");
await setTaskState(loopCtx, "kill_finalize");
const db = loopCtx.db;
await db.update(taskTable).set({ status: "killed", updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run();
await db.update(taskRuntime).set({ statusMessage: "killed", updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run();
await appendHistory(loopCtx, "task.kill", { reason: msg.body?.reason ?? null });
await appendAuditLog(loopCtx, "task.kill", { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true });
}

View file

@ -2,8 +2,8 @@
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 { historyKey } from "../../keys.js";
import { broadcastTaskUpdate } from "../workbench.js";
import { getOrCreateAuditLog } from "../../handles.js";
import { broadcastTaskUpdate } from "../workspace.js";
export const TASK_ROW_ID = 1;
@ -56,33 +56,11 @@ export function buildAgentPrompt(task: string): string {
return task.trim();
}
export async function setTaskState(ctx: any, status: TaskStatus, statusMessage?: string): Promise<void> {
export async function setTaskState(ctx: any, status: TaskStatus): Promise<void> {
const now = Date.now();
const db = ctx.db;
await db.update(taskTable).set({ status, updatedAt: now }).where(eq(taskTable.id, TASK_ROW_ID)).run();
if (statusMessage != null) {
await db
.insert(taskRuntime)
.values({
id: TASK_ROW_ID,
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskRuntime.id,
set: {
statusMessage,
updatedAt: now,
},
})
.run();
}
await broadcastTaskUpdate(ctx);
}
@ -95,11 +73,7 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
task: taskTable.task,
sandboxProviderId: taskTable.sandboxProviderId,
status: taskTable.status,
statusMessage: taskRuntime.statusMessage,
activeSandboxId: taskRuntime.activeSandboxId,
activeSessionId: taskRuntime.activeSessionId,
agentType: taskTable.agentType,
prSubmitted: taskTable.prSubmitted,
createdAt: taskTable.createdAt,
updatedAt: taskTable.updatedAt,
})
@ -135,9 +109,7 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
task: row.task,
sandboxProviderId: row.sandboxProviderId,
status: row.status,
statusMessage: row.statusMessage ?? null,
activeSandboxId: row.activeSandboxId ?? null,
activeSessionId: row.activeSessionId ?? null,
sandboxes: sandboxes.map((sb) => ({
sandboxId: sb.sandboxId,
sandboxProviderId: sb.sandboxProviderId,
@ -147,12 +119,7 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
createdAt: sb.createdAt,
updatedAt: sb.updatedAt,
})),
agentType: row.agentType ?? null,
prSubmitted: Boolean(row.prSubmitted),
diffStat: null,
hasUnpushed: null,
conflictsWithMain: null,
parentBranch: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
@ -163,17 +130,20 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
} as TaskRecord;
}
export async function appendHistory(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
const client = ctx.client();
const history = await client.history.getOrCreate(historyKey(ctx.state.organizationId, ctx.state.repoId), {
createWithInput: { organizationId: ctx.state.organizationId, repoId: ctx.state.repoId },
});
await history.append({
kind,
taskId: ctx.state.taskId,
branchName: ctx.state.branchName,
payload,
});
export async function appendAuditLog(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
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,
payload,
},
{
wait: false,
},
);
await broadcastTaskUpdate(ctx);
}

View file

@ -14,24 +14,23 @@ import {
} from "./commands.js";
import { TASK_QUEUE_NAMES } from "./queue.js";
import {
changeWorkbenchModel,
closeWorkbenchSession,
createWorkbenchSession,
ensureWorkbenchSession,
refreshWorkbenchDerivedState,
refreshWorkbenchSessionTranscript,
markWorkbenchUnread,
publishWorkbenchPr,
renameWorkbenchBranch,
renameWorkbenchTask,
renameWorkbenchSession,
revertWorkbenchFile,
sendWorkbenchMessage,
setWorkbenchSessionUnread,
stopWorkbenchSession,
syncWorkbenchSessionStatus,
updateWorkbenchDraft,
} from "../workbench.js";
changeWorkspaceModel,
closeWorkspaceSession,
createWorkspaceSession,
ensureWorkspaceSession,
refreshWorkspaceDerivedState,
refreshWorkspaceSessionTranscript,
markWorkspaceUnread,
publishWorkspacePr,
renameWorkspaceTask,
renameWorkspaceSession,
revertWorkspaceFile,
sendWorkspaceMessage,
setWorkspaceSessionUnread,
stopWorkspaceSession,
syncWorkspaceSessionStatus,
updateWorkspaceDraft,
} from "../workspace.js";
export { TASK_QUEUE_NAMES, taskWorkflowQueueName } from "./queue.js";
@ -113,31 +112,22 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
await loopCtx.step("handle-get", async () => handleGetActivity(loopCtx, msg));
},
"task.command.workbench.mark_unread": async (loopCtx, msg) => {
await loopCtx.step("workbench-mark-unread", async () => markWorkbenchUnread(loopCtx));
"task.command.workspace.mark_unread": async (loopCtx, msg) => {
await loopCtx.step("workspace-mark-unread", async () => markWorkspaceUnread(loopCtx));
await msg.complete({ ok: true });
},
"task.command.workbench.rename_task": async (loopCtx, msg) => {
await loopCtx.step("workbench-rename-task", async () => renameWorkbenchTask(loopCtx, msg.body.value));
"task.command.workspace.rename_task": async (loopCtx, msg) => {
await loopCtx.step("workspace-rename-task", async () => renameWorkspaceTask(loopCtx, msg.body.value));
await msg.complete({ ok: true });
},
"task.command.workbench.rename_branch": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-rename-branch",
timeout: 5 * 60_000,
run: async () => renameWorkbenchBranch(loopCtx, msg.body.value),
});
await msg.complete({ ok: true });
},
"task.command.workbench.create_session": async (loopCtx, msg) => {
"task.command.workspace.create_session": async (loopCtx, msg) => {
try {
const created = await loopCtx.step({
name: "workbench-create-session",
name: "workspace-create-session",
timeout: 5 * 60_000,
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
run: async () => createWorkspaceSession(loopCtx, msg.body?.model),
});
await msg.complete(created);
} catch (error) {
@ -145,17 +135,17 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
}
},
"task.command.workbench.create_session_and_send": async (loopCtx, msg) => {
"task.command.workspace.create_session_and_send": async (loopCtx, msg) => {
try {
const created = await loopCtx.step({
name: "workbench-create-session-for-send",
name: "workspace-create-session-for-send",
timeout: 5 * 60_000,
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
run: async () => createWorkspaceSession(loopCtx, msg.body?.model),
});
await loopCtx.step({
name: "workbench-send-initial-message",
name: "workspace-send-initial-message",
timeout: 5 * 60_000,
run: async () => sendWorkbenchMessage(loopCtx, created.sessionId, msg.body.text, []),
run: async () => sendWorkspaceMessage(loopCtx, created.sessionId, msg.body.text, []),
});
} catch (error) {
logActorWarning("task.workflow", "create_session_and_send failed", {
@ -165,41 +155,41 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
await msg.complete({ ok: true });
},
"task.command.workbench.ensure_session": async (loopCtx, msg) => {
"task.command.workspace.ensure_session": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-ensure-session",
name: "workspace-ensure-session",
timeout: 5 * 60_000,
run: async () => ensureWorkbenchSession(loopCtx, msg.body.sessionId, msg.body?.model),
run: async () => ensureWorkspaceSession(loopCtx, msg.body.sessionId, msg.body?.model),
});
await msg.complete({ ok: true });
},
"task.command.workbench.rename_session": async (loopCtx, msg) => {
await loopCtx.step("workbench-rename-session", async () => renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title));
"task.command.workspace.rename_session": async (loopCtx, msg) => {
await loopCtx.step("workspace-rename-session", async () => renameWorkspaceSession(loopCtx, msg.body.sessionId, msg.body.title));
await msg.complete({ ok: true });
},
"task.command.workbench.set_session_unread": async (loopCtx, msg) => {
await loopCtx.step("workbench-set-session-unread", async () => setWorkbenchSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread));
"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));
await msg.complete({ ok: true });
},
"task.command.workbench.update_draft": async (loopCtx, msg) => {
await loopCtx.step("workbench-update-draft", async () => updateWorkbenchDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments));
"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));
await msg.complete({ ok: true });
},
"task.command.workbench.change_model": async (loopCtx, msg) => {
await loopCtx.step("workbench-change-model", async () => changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model));
"task.command.workspace.change_model": async (loopCtx, msg) => {
await loopCtx.step("workspace-change-model", async () => changeWorkspaceModel(loopCtx, msg.body.sessionId, msg.body.model));
await msg.complete({ ok: true });
},
"task.command.workbench.send_message": async (loopCtx, msg) => {
"task.command.workspace.send_message": async (loopCtx, msg) => {
try {
await loopCtx.step({
name: "workbench-send-message",
name: "workspace-send-message",
timeout: 10 * 60_000,
run: async () => sendWorkbenchMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
run: async () => sendWorkspaceMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
});
await msg.complete({ ok: true });
} catch (error) {
@ -207,61 +197,61 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
}
},
"task.command.workbench.stop_session": async (loopCtx, msg) => {
"task.command.workspace.stop_session": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-stop-session",
name: "workspace-stop-session",
timeout: 5 * 60_000,
run: async () => stopWorkbenchSession(loopCtx, msg.body.sessionId),
run: async () => stopWorkspaceSession(loopCtx, msg.body.sessionId),
});
await msg.complete({ ok: true });
},
"task.command.workbench.sync_session_status": async (loopCtx, msg) => {
await loopCtx.step("workbench-sync-session-status", async () => syncWorkbenchSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at));
"task.command.workspace.sync_session_status": async (loopCtx, msg) => {
await loopCtx.step("workspace-sync-session-status", async () => syncWorkspaceSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at));
await msg.complete({ ok: true });
},
"task.command.workbench.refresh_derived": async (loopCtx, msg) => {
"task.command.workspace.refresh_derived": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-refresh-derived",
name: "workspace-refresh-derived",
timeout: 5 * 60_000,
run: async () => refreshWorkbenchDerivedState(loopCtx),
run: async () => refreshWorkspaceDerivedState(loopCtx),
});
await msg.complete({ ok: true });
},
"task.command.workbench.refresh_session_transcript": async (loopCtx, msg) => {
"task.command.workspace.refresh_session_transcript": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-refresh-session-transcript",
name: "workspace-refresh-session-transcript",
timeout: 60_000,
run: async () => refreshWorkbenchSessionTranscript(loopCtx, msg.body.sessionId),
run: async () => refreshWorkspaceSessionTranscript(loopCtx, msg.body.sessionId),
});
await msg.complete({ ok: true });
},
"task.command.workbench.close_session": async (loopCtx, msg) => {
"task.command.workspace.close_session": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-close-session",
name: "workspace-close-session",
timeout: 5 * 60_000,
run: async () => closeWorkbenchSession(loopCtx, msg.body.sessionId),
run: async () => closeWorkspaceSession(loopCtx, msg.body.sessionId),
});
await msg.complete({ ok: true });
},
"task.command.workbench.publish_pr": async (loopCtx, msg) => {
"task.command.workspace.publish_pr": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-publish-pr",
name: "workspace-publish-pr",
timeout: 10 * 60_000,
run: async () => publishWorkbenchPr(loopCtx),
run: async () => publishWorkspacePr(loopCtx),
});
await msg.complete({ ok: true });
},
"task.command.workbench.revert_file": async (loopCtx, msg) => {
"task.command.workspace.revert_file": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-revert-file",
name: "workspace-revert-file",
timeout: 5 * 60_000,
run: async () => revertWorkbenchFile(loopCtx, msg.body.path),
run: async () => revertWorkspaceFile(loopCtx, msg.body.path),
});
await msg.complete({ ok: true });
},

View file

@ -1,27 +1,18 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../../context.js";
import { getOrCreateHistory, selfTask } from "../../handles.js";
import { selfTask } from "../../handles.js";
import { resolveErrorMessage } from "../../logging.js";
import { defaultSandboxProviderId } from "../../../sandbox-config.js";
import { task as taskTable, taskRuntime } from "../db/schema.js";
import { TASK_ROW_ID, appendHistory, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js";
import { TASK_ROW_ID, appendAuditLog, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js";
import { taskWorkflowQueueName } from "./queue.js";
async function ensureTaskRuntimeCacheColumns(db: any): Promise<void> {
await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_json text`).catch(() => {});
await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_updated_at integer`).catch(() => {});
await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage text`).catch(() => {});
await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage_updated_at integer`).catch(() => {});
}
export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<void> {
const { config } = getActorRuntimeContext();
const sandboxProviderId = body?.sandboxProviderId ?? loopCtx.state.sandboxProviderId ?? defaultSandboxProviderId(config);
const sandboxProviderId = body?.sandboxProviderId ?? defaultSandboxProviderId(config);
const now = Date.now();
await ensureTaskRuntimeCacheColumns(loopCtx.db);
await loopCtx.db
.insert(taskTable)
.values({
@ -31,7 +22,6 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
task: loopCtx.state.task,
sandboxProviderId,
status: "init_bootstrap_db",
agentType: loopCtx.state.agentType ?? config.default_agent,
createdAt: now,
updatedAt: now,
})
@ -43,7 +33,6 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
task: loopCtx.state.task,
sandboxProviderId,
status: "init_bootstrap_db",
agentType: loopCtx.state.agentType ?? config.default_agent,
updatedAt: now,
},
})
@ -54,26 +43,18 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
.values({
id: TASK_ROW_ID,
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: "provisioning",
gitStateJson: null,
gitStateUpdatedAt: null,
provisionStage: "queued",
provisionStageUpdatedAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskRuntime.id,
set: {
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: "provisioning",
provisionStage: "queued",
provisionStageUpdatedAt: now,
updatedAt: now,
},
})
@ -81,16 +62,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
}
export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise<void> {
await setTaskState(loopCtx, "init_enqueue_provision", "provision queued");
await loopCtx.db
.update(taskRuntime)
.set({
provisionStage: "queued",
provisionStageUpdatedAt: Date.now(),
updatedAt: Date.now(),
})
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
await setTaskState(loopCtx, "init_enqueue_provision");
const self = selfTask(loopCtx);
try {
@ -111,29 +83,20 @@ export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Pro
export async function initCompleteActivity(loopCtx: any, body: any): Promise<void> {
const now = Date.now();
const { config } = getActorRuntimeContext();
const sandboxProviderId = body?.sandboxProviderId ?? loopCtx.state.sandboxProviderId ?? defaultSandboxProviderId(config);
const sandboxProviderId = body?.sandboxProviderId ?? defaultSandboxProviderId(config);
await setTaskState(loopCtx, "init_complete", "task initialized");
await setTaskState(loopCtx, "init_complete");
await loopCtx.db
.update(taskRuntime)
.set({
statusMessage: "ready",
provisionStage: "ready",
provisionStageUpdatedAt: now,
updatedAt: now,
})
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
const history = await getOrCreateHistory(loopCtx, loopCtx.state.organizationId, loopCtx.state.repoId);
await history.append({
kind: "task.initialized",
taskId: loopCtx.state.taskId,
branchName: loopCtx.state.branchName,
await appendAuditLog(loopCtx, "task.initialized", {
payload: { sandboxProviderId },
});
loopCtx.state.initialized = true;
}
export async function initFailedActivity(loopCtx: any, error: unknown): Promise<void> {
@ -141,7 +104,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
const detail = resolveErrorDetail(error);
const messages = collectErrorMessages(error);
const { config } = getActorRuntimeContext();
const sandboxProviderId = loopCtx.state.sandboxProviderId ?? defaultSandboxProviderId(config);
const sandboxProviderId = defaultSandboxProviderId(config);
await loopCtx.db
.insert(taskTable)
@ -152,7 +115,6 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
task: loopCtx.state.task,
sandboxProviderId,
status: "error",
agentType: loopCtx.state.agentType ?? config.default_agent,
createdAt: now,
updatedAt: now,
})
@ -164,7 +126,6 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
task: loopCtx.state.task,
sandboxProviderId,
status: "error",
agentType: loopCtx.state.agentType ?? config.default_agent,
updatedAt: now,
},
})
@ -175,30 +136,22 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
.values({
id: TASK_ROW_ID,
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: detail,
provisionStage: "error",
provisionStageUpdatedAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskRuntime.id,
set: {
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: detail,
provisionStage: "error",
provisionStageUpdatedAt: now,
updatedAt: now,
},
})
.run();
await appendHistory(loopCtx, "task.error", {
await appendAuditLog(loopCtx, "task.error", {
detail,
messages,
});

View file

@ -2,8 +2,8 @@
import { eq } from "drizzle-orm";
import { getTaskSandbox } from "../../handles.js";
import { resolveOrganizationGithubAuth } from "../../../services/github-auth.js";
import { taskRuntime, taskSandboxes } from "../db/schema.js";
import { TASK_ROW_ID, appendHistory, getCurrentRecord } from "./common.js";
import { taskSandboxes } from "../db/schema.js";
import { appendAuditLog, getCurrentRecord } from "./common.js";
export interface PushActiveBranchOptions {
reason?: string | null;
@ -29,12 +29,6 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive
}
const now = Date.now();
await loopCtx.db
.update(taskRuntime)
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
await loopCtx.db
.update(taskSandboxes)
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
@ -69,19 +63,13 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive
}
const updatedAt = Date.now();
await loopCtx.db
.update(taskRuntime)
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
await loopCtx.db
.update(taskSandboxes)
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
.run();
await appendHistory(loopCtx, options.historyKind ?? "task.push", {
await appendAuditLog(loopCtx, options.historyKind ?? "task.push", {
reason: options.reason ?? null,
branchName,
sandboxId: activeSandboxId,

View file

@ -9,24 +9,23 @@ export const TASK_QUEUE_NAMES = [
"task.command.archive",
"task.command.kill",
"task.command.get",
"task.command.workbench.mark_unread",
"task.command.workbench.rename_task",
"task.command.workbench.rename_branch",
"task.command.workbench.create_session",
"task.command.workbench.create_session_and_send",
"task.command.workbench.ensure_session",
"task.command.workbench.rename_session",
"task.command.workbench.set_session_unread",
"task.command.workbench.update_draft",
"task.command.workbench.change_model",
"task.command.workbench.send_message",
"task.command.workbench.stop_session",
"task.command.workbench.sync_session_status",
"task.command.workbench.refresh_derived",
"task.command.workbench.refresh_session_transcript",
"task.command.workbench.close_session",
"task.command.workbench.publish_pr",
"task.command.workbench.revert_file",
"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.set_session_unread",
"task.command.workspace.update_draft",
"task.command.workspace.change_model",
"task.command.workspace.send_message",
"task.command.workspace.stop_session",
"task.command.workspace.sync_session_status",
"task.command.workspace.refresh_derived",
"task.command.workspace.refresh_session_transcript",
"task.command.workspace.close_session",
"task.command.workspace.publish_pr",
"task.command.workspace.revert_file",
] as const;
export function taskWorkflowQueueName(name: string): string {

View file

@ -3,12 +3,12 @@ import { randomUUID } from "node:crypto";
import { basename, dirname } from "node:path";
import { asc, eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateRepository, getOrCreateTaskSandbox, getOrCreateOrganization, getTaskSandbox, selfTask } from "../handles.js";
import { getOrCreateRepository, getOrCreateTaskSandbox, getTaskSandbox, selfTask } from "../handles.js";
import { SANDBOX_REPO_CWD } from "../sandbox/index.js";
import { resolveSandboxProviderId } from "../../sandbox-config.js";
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
import { githubRepoFullNameFromRemote } from "../../services/repo.js";
import { task as taskTable, taskRuntime, taskSandboxes, taskWorkbenchSessions } from "./db/schema.js";
import { task as taskTable, taskRuntime, taskSandboxes, taskWorkspaceSessions } from "./db/schema.js";
import { getCurrentRecord } from "./workflow/common.js";
function emptyGitState() {
@ -20,42 +20,6 @@ function emptyGitState() {
};
}
async function ensureWorkbenchSessionTable(c: any): Promise<void> {
await c.db.execute(`
CREATE TABLE IF NOT EXISTS task_workbench_sessions (
session_id text PRIMARY KEY NOT NULL,
sandbox_session_id text,
session_name text NOT NULL,
model text NOT NULL,
status text DEFAULT 'ready' NOT NULL,
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,
created_at integer NOT NULL,
updated_at integer NOT NULL
)
`);
await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN sandbox_session_id text`).catch(() => {});
await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN status text DEFAULT 'ready' NOT NULL`).catch(() => {});
await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN error_message text`).catch(() => {});
await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN transcript_json text DEFAULT '[]' NOT NULL`).catch(() => {});
await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN transcript_updated_at integer`).catch(() => {});
}
async function ensureTaskRuntimeCacheColumns(c: any): Promise<void> {
await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_json text`).catch(() => {});
await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_updated_at integer`).catch(() => {});
await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage text`).catch(() => {});
await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage_updated_at integer`).catch(() => {});
}
function defaultModelForAgent(agentType: string | null | undefined) {
return agentType === "codex" ? "gpt-5.3-codex" : "claude-sonnet-4";
}
@ -168,8 +132,7 @@ export function shouldRecreateSessionForModelChange(meta: {
}
async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise<Array<any>> {
await ensureWorkbenchSessionTable(c);
const rows = await c.db.select().from(taskWorkbenchSessions).orderBy(asc(taskWorkbenchSessions.createdAt)).all();
const rows = await c.db.select().from(taskWorkspaceSessions).orderBy(asc(taskWorkspaceSessions.createdAt)).all();
const mapped = rows.map((row: any) => ({
...row,
id: row.sessionId,
@ -199,8 +162,7 @@ async function nextSessionName(c: any): Promise<string> {
}
async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
await ensureWorkbenchSessionTable(c);
const row = await c.db.select().from(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.sessionId, sessionId)).get();
const row = await c.db.select().from(taskWorkspaceSessions).where(eq(taskWorkspaceSessions.sessionId, sessionId)).get();
if (!row) {
return null;
@ -236,7 +198,6 @@ async function ensureSessionMeta(
errorMessage?: string | null;
},
): Promise<any> {
await ensureWorkbenchSessionTable(c);
const existing = await readSessionMeta(c, params.sessionId);
if (existing) {
return existing;
@ -248,7 +209,7 @@ async function ensureSessionMeta(
const unread = params.unread ?? false;
await c.db
.insert(taskWorkbenchSessions)
.insert(taskWorkspaceSessions)
.values({
sessionId: params.sessionId,
sandboxSessionId: params.sandboxSessionId ?? null,
@ -276,19 +237,18 @@ async function ensureSessionMeta(
async function updateSessionMeta(c: any, sessionId: string, values: Record<string, unknown>): Promise<any> {
await ensureSessionMeta(c, { sessionId });
await c.db
.update(taskWorkbenchSessions)
.update(taskWorkspaceSessions)
.set({
...values,
updatedAt: Date.now(),
})
.where(eq(taskWorkbenchSessions.sessionId, sessionId))
.where(eq(taskWorkspaceSessions.sessionId, sessionId))
.run();
return await readSessionMeta(c, sessionId);
}
async function readSessionMetaBySandboxSessionId(c: any, sandboxSessionId: string): Promise<any | null> {
await ensureWorkbenchSessionTable(c);
const row = await c.db.select().from(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.sandboxSessionId, sandboxSessionId)).get();
const row = await c.db.select().from(taskWorkspaceSessions).where(eq(taskWorkspaceSessions.sandboxSessionId, sandboxSessionId)).get();
if (!row) {
return null;
}
@ -298,17 +258,17 @@ async function readSessionMetaBySandboxSessionId(c: any, sandboxSessionId: strin
async function requireReadySessionMeta(c: any, sessionId: string): Promise<any> {
const meta = await readSessionMeta(c, sessionId);
if (!meta) {
throw new Error(`Unknown workbench session: ${sessionId}`);
throw new Error(`Unknown workspace session: ${sessionId}`);
}
if (meta.status !== "ready" || !meta.sandboxSessionId) {
throw new Error(meta.errorMessage ?? "This workbench session is still preparing");
throw new Error(meta.errorMessage ?? "This workspace session is still preparing");
}
return meta;
}
export function requireSendableSessionMeta(meta: any, sessionId: string): any {
if (!meta) {
throw new Error(`Unknown workbench session: ${sessionId}`);
throw new Error(`Unknown workspace session: ${sessionId}`);
}
if (meta.status !== "ready" || !meta.sandboxSessionId) {
throw new Error(`Session is not ready (status: ${meta.status}). Wait for session provisioning to complete.`);
@ -389,7 +349,7 @@ async function getTaskSandboxRuntime(
/**
* Track whether the sandbox repo has been fully prepared (cloned + fetched + checked out)
* for the current actor lifecycle. Subsequent calls can skip the expensive `git fetch`
* when `skipFetch` is true (used by sendWorkbenchMessage to avoid blocking on every prompt).
* when `skipFetch` is true (used by sendWorkspaceMessage to avoid blocking on every prompt).
*/
let sandboxRepoPrepared = false;
@ -452,7 +412,7 @@ async function executeInSandbox(
label: string;
},
): Promise<{ exitCode: number; result: string }> {
const record = await ensureWorkbenchSeeded(c);
const record = await ensureWorkspaceSeeded(c);
const runtime = await getTaskSandboxRuntime(c, record);
await ensureSandboxRepo(c, runtime.sandbox, record);
const response = await runtime.sandbox.runProcess({
@ -555,7 +515,7 @@ function buildFileTree(paths: string[]): Array<any> {
return sortNodes(root.children.values());
}
async function collectWorkbenchGitState(c: any, record: any) {
async function collectWorkspaceGitState(c: any, record: any) {
const activeSandboxId = record.activeSandboxId;
const activeSandbox = activeSandboxId != null ? ((record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === activeSandboxId) ?? null) : null;
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
@ -628,7 +588,6 @@ async function collectWorkbenchGitState(c: any, record: any) {
}
async function readCachedGitState(c: any): Promise<{ fileChanges: Array<any>; diffs: Record<string, string>; fileTree: Array<any>; updatedAt: number | null }> {
await ensureTaskRuntimeCacheColumns(c);
const row = await c.db
.select({
gitStateJson: taskRuntime.gitStateJson,
@ -645,7 +604,6 @@ async function readCachedGitState(c: any): Promise<{ fileChanges: Array<any>; di
}
async function writeCachedGitState(c: any, gitState: { fileChanges: Array<any>; diffs: Record<string, string>; fileTree: Array<any> }): Promise<void> {
await ensureTaskRuntimeCacheColumns(c);
const now = Date.now();
await c.db
.update(taskRuntime)
@ -687,19 +645,19 @@ async function writeSessionTranscript(c: any, sessionId: string, transcript: Arr
});
}
async function enqueueWorkbenchRefresh(
async function enqueueWorkspaceRefresh(
c: any,
command: "task.command.workbench.refresh_derived" | "task.command.workbench.refresh_session_transcript",
command: "task.command.workspace.refresh_derived" | "task.command.workspace.refresh_session_transcript",
body: Record<string, unknown>,
): Promise<void> {
const self = selfTask(c);
await self.send(command, body, { wait: false });
}
async function enqueueWorkbenchEnsureSession(c: any, sessionId: string): Promise<void> {
async function enqueueWorkspaceEnsureSession(c: any, sessionId: string): Promise<void> {
const self = selfTask(c);
await self.send(
"task.command.workbench.ensure_session",
"task.command.workspace.ensure_session",
{
sessionId,
},
@ -709,21 +667,21 @@ async function enqueueWorkbenchEnsureSession(c: any, sessionId: string): Promise
);
}
function pendingWorkbenchSessionStatus(record: any): "pending_provision" | "pending_session_create" {
function pendingWorkspaceSessionStatus(record: any): "pending_provision" | "pending_session_create" {
return record.activeSandboxId ? "pending_session_create" : "pending_provision";
}
async function maybeScheduleWorkbenchRefreshes(c: any, record: any, sessions: Array<any>): Promise<void> {
async function maybeScheduleWorkspaceRefreshes(c: any, record: any, sessions: Array<any>): Promise<void> {
const gitState = await readCachedGitState(c);
if (record.activeSandboxId && !gitState.updatedAt) {
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_derived", {});
}
for (const session of sessions) {
if (session.closed || session.status !== "ready" || !session.sandboxSessionId || session.transcriptUpdatedAt) {
continue;
}
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", {
sessionId: session.sandboxSessionId,
});
}
@ -756,8 +714,7 @@ async function readPullRequestSummary(c: any, branchName: string | null) {
}
}
export async function ensureWorkbenchSeeded(c: any): Promise<any> {
await ensureTaskRuntimeCacheColumns(c);
export async function ensureWorkspaceSeeded(c: any): Promise<any> {
const record = await getCurrentRecord({ db: c.db, state: c.state });
if (record.activeSessionId) {
await ensureSessionMeta(c, {
@ -826,13 +783,13 @@ function buildSessionDetailFromMeta(record: any, meta: any): any {
}
/**
* Builds a WorkbenchTaskSummary from local task actor state. Task actors push
* Builds a WorkspaceTaskSummary from local task actor state. Task actors push
* this to the parent organization actor so organization sidebar reads stay local.
*/
export async function buildTaskSummary(c: any): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
const record = await ensureWorkspaceSeeded(c);
const sessions = await listSessionMetaRows(c);
await maybeScheduleWorkbenchRefreshes(c, record, sessions);
await maybeScheduleWorkspaceRefreshes(c, record, sessions);
return {
id: c.state.taskId,
@ -848,14 +805,14 @@ export async function buildTaskSummary(c: any): Promise<any> {
}
/**
* Builds a WorkbenchTaskDetail from local task actor state for direct task
* Builds a WorkspaceTaskDetail from local task actor state for direct task
* subscribers. This is a full replacement payload, not a patch.
*/
export async function buildTaskDetail(c: any): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
const record = await ensureWorkspaceSeeded(c);
const gitState = await readCachedGitState(c);
const sessions = await listSessionMetaRows(c);
await maybeScheduleWorkbenchRefreshes(c, record, sessions);
await maybeScheduleWorkspaceRefreshes(c, record, sessions);
const summary = await buildTaskSummary(c);
return {
@ -882,13 +839,13 @@ export async function buildTaskDetail(c: any): Promise<any> {
}
/**
* Builds a WorkbenchSessionDetail for a specific session.
* Builds a WorkspaceSessionDetail for a specific session.
*/
export async function buildSessionDetail(c: any, sessionId: string): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
const record = await ensureWorkspaceSeeded(c);
const meta = await readSessionMeta(c, sessionId);
if (!meta || meta.closed) {
throw new Error(`Unknown workbench session: ${sessionId}`);
throw new Error(`Unknown workspace session: ${sessionId}`);
}
if (!meta.sandboxSessionId) {
@ -925,7 +882,7 @@ export async function getSessionDetail(c: any, sessionId: string): Promise<any>
}
/**
* Replaces the old notifyWorkbenchUpdated pattern.
* Replaces the old notifyWorkspaceUpdated pattern.
*
* The task actor emits two kinds of updates:
* - Push summary state up to the parent organization actor so the sidebar
@ -933,10 +890,10 @@ export async function getSessionDetail(c: any, sessionId: string): Promise<any>
* - Broadcast full detail/session payloads down to direct task subscribers.
*/
export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }): Promise<void> {
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) });
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote);
await repository.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) });
c.broadcast("taskUpdated", {
type: "taskDetailUpdated",
type: "taskUpdated",
detail: await buildTaskDetail(c),
});
@ -948,15 +905,15 @@ export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string
}
}
export async function refreshWorkbenchDerivedState(c: any): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
const gitState = await collectWorkbenchGitState(c, record);
export async function refreshWorkspaceDerivedState(c: any): Promise<void> {
const record = await ensureWorkspaceSeeded(c);
const gitState = await collectWorkspaceGitState(c, record);
await writeCachedGitState(c, gitState);
await broadcastTaskUpdate(c);
}
export async function refreshWorkbenchSessionTranscript(c: any, sessionId: string): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
export async function refreshWorkspaceSessionTranscript(c: any, sessionId: string): Promise<void> {
const record = await ensureWorkspaceSeeded(c);
const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await readSessionMeta(c, sessionId));
if (!meta?.sandboxSessionId) {
return;
@ -967,7 +924,7 @@ export async function refreshWorkbenchSessionTranscript(c: any, sessionId: strin
await broadcastTaskUpdate(c, { sessionId: meta.sessionId });
}
export async function renameWorkbenchTask(c: any, value: string): Promise<void> {
export async function renameWorkspaceTask(c: any, value: string): Promise<void> {
const nextTitle = value.trim();
if (!nextTitle) {
throw new Error("task title is required");
@ -985,81 +942,30 @@ export async function renameWorkbenchTask(c: any, value: string): Promise<void>
await broadcastTaskUpdate(c);
}
export async function renameWorkbenchBranch(c: any, value: string): Promise<void> {
const nextBranch = value.trim();
if (!nextBranch) {
throw new Error("branch name is required");
}
const record = await ensureWorkbenchSeeded(c);
if (!record.branchName) {
throw new Error("cannot rename branch before task branch exists");
}
if (!record.activeSandboxId) {
throw new Error("cannot rename branch without an active sandbox");
}
const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
if (!activeSandbox?.cwd) {
throw new Error("cannot rename branch without a sandbox cwd");
}
const renameResult = await executeInSandbox(c, {
sandboxId: record.activeSandboxId,
cwd: activeSandbox.cwd,
command: [
`git branch -m ${JSON.stringify(record.branchName)} ${JSON.stringify(nextBranch)}`,
`if git ls-remote --exit-code --heads origin ${JSON.stringify(record.branchName)} >/dev/null 2>&1; then git push origin :${JSON.stringify(record.branchName)}; fi`,
`git push origin ${JSON.stringify(nextBranch)}`,
`git branch --set-upstream-to=${JSON.stringify(`origin/${nextBranch}`)} ${JSON.stringify(nextBranch)} || git push --set-upstream origin ${JSON.stringify(nextBranch)}`,
].join(" && "),
label: `git branch -m ${record.branchName} ${nextBranch}`,
});
if (renameResult.exitCode !== 0) {
throw new Error(`branch rename failed (${renameResult.exitCode}): ${renameResult.result}`);
}
await c.db
.update(taskTable)
.set({
branchName: nextBranch,
updatedAt: Date.now(),
})
.where(eq(taskTable.id, 1))
.run();
c.state.branchName = nextBranch;
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote);
await repository.registerTaskBranch({
taskId: c.state.taskId,
branchName: nextBranch,
});
await broadcastTaskUpdate(c);
}
export async function createWorkbenchSession(c: any, model?: string): Promise<{ sessionId: string }> {
export async function createWorkspaceSession(c: any, model?: string): Promise<{ sessionId: string }> {
const sessionId = `session-${randomUUID()}`;
const record = await ensureWorkbenchSeeded(c);
const record = await ensureWorkspaceSeeded(c);
await ensureSessionMeta(c, {
sessionId,
model: model ?? defaultModelForAgent(record.agentType),
sandboxSessionId: null,
status: pendingWorkbenchSessionStatus(record),
status: pendingWorkspaceSessionStatus(record),
created: false,
});
await broadcastTaskUpdate(c, { sessionId: sessionId });
await enqueueWorkbenchEnsureSession(c, sessionId);
await enqueueWorkspaceEnsureSession(c, sessionId);
return { sessionId };
}
export async function ensureWorkbenchSession(c: any, sessionId: string, model?: string): Promise<void> {
export async function ensureWorkspaceSession(c: any, sessionId: string, model?: string): Promise<void> {
const meta = await readSessionMeta(c, sessionId);
if (!meta || meta.closed) {
return;
}
const record = await ensureWorkbenchSeeded(c);
const record = await ensureWorkspaceSeeded(c);
if (meta.sandboxSessionId && meta.status === "ready") {
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", {
sessionId: meta.sandboxSessionId,
});
await broadcastTaskUpdate(c, { sessionId: sessionId });
@ -1089,7 +995,7 @@ export async function ensureWorkbenchSession(c: any, sessionId: string, model?:
status: "ready",
errorMessage: null,
});
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", {
sessionId: meta.sandboxSessionId ?? sessionId,
});
} catch (error) {
@ -1102,7 +1008,7 @@ export async function ensureWorkbenchSession(c: any, sessionId: string, model?:
await broadcastTaskUpdate(c, { sessionId: sessionId });
}
export async function enqueuePendingWorkbenchSessions(c: any): Promise<void> {
export async function enqueuePendingWorkspaceSessions(c: any): Promise<void> {
const self = selfTask(c);
const pending = (await listSessionMetaRows(c, { includeClosed: true })).filter(
(row) => row.closed !== true && row.status !== "ready" && row.status !== "error",
@ -1110,7 +1016,7 @@ export async function enqueuePendingWorkbenchSessions(c: any): Promise<void> {
for (const row of pending) {
await self.send(
"task.command.workbench.ensure_session",
"task.command.workspace.ensure_session",
{
sessionId: row.sessionId,
model: row.model,
@ -1122,7 +1028,7 @@ export async function enqueuePendingWorkbenchSessions(c: any): Promise<void> {
}
}
export async function renameWorkbenchSession(c: any, sessionId: string, title: string): Promise<void> {
export async function renameWorkspaceSession(c: any, sessionId: string, title: string): Promise<void> {
const trimmed = title.trim();
if (!trimmed) {
throw new Error("session title is required");
@ -1133,14 +1039,14 @@ export async function renameWorkbenchSession(c: any, sessionId: string, title: s
await broadcastTaskUpdate(c, { sessionId });
}
export async function setWorkbenchSessionUnread(c: any, sessionId: string, unread: boolean): Promise<void> {
export async function setWorkspaceSessionUnread(c: any, sessionId: string, unread: boolean): Promise<void> {
await updateSessionMeta(c, sessionId, {
unread: unread ? 1 : 0,
});
await broadcastTaskUpdate(c, { sessionId });
}
export async function updateWorkbenchDraft(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
export async function updateWorkspaceDraft(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
await updateSessionMeta(c, sessionId, {
draftText: text,
draftAttachmentsJson: JSON.stringify(attachments),
@ -1149,7 +1055,7 @@ export async function updateWorkbenchDraft(c: any, sessionId: string, text: stri
await broadcastTaskUpdate(c, { sessionId });
}
export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise<void> {
export async function changeWorkspaceModel(c: any, sessionId: string, model: string): Promise<void> {
const meta = await readSessionMeta(c, sessionId);
if (!meta || meta.closed) {
return;
@ -1159,7 +1065,7 @@ export async function changeWorkbenchModel(c: any, sessionId: string, model: str
return;
}
const record = await ensureWorkbenchSeeded(c);
const record = await ensureWorkspaceSeeded(c);
let nextMeta = await updateSessionMeta(c, sessionId, {
model,
});
@ -1170,7 +1076,7 @@ export async function changeWorkbenchModel(c: any, sessionId: string, model: str
await sandbox.destroySession(nextMeta.sandboxSessionId);
nextMeta = await updateSessionMeta(c, sessionId, {
sandboxSessionId: null,
status: pendingWorkbenchSessionStatus(record),
status: pendingWorkspaceSessionStatus(record),
errorMessage: null,
transcriptJson: "[]",
transcriptUpdatedAt: null,
@ -1191,20 +1097,20 @@ export async function changeWorkbenchModel(c: any, sessionId: string, model: str
}
} else if (nextMeta.status !== "ready") {
nextMeta = await updateSessionMeta(c, sessionId, {
status: pendingWorkbenchSessionStatus(record),
status: pendingWorkspaceSessionStatus(record),
errorMessage: null,
});
}
if (shouldEnsure) {
await enqueueWorkbenchEnsureSession(c, sessionId);
await enqueueWorkspaceEnsureSession(c, sessionId);
}
await broadcastTaskUpdate(c, { sessionId });
}
export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
export async function sendWorkspaceMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
const meta = requireSendableSessionMeta(await readSessionMeta(c, sessionId), sessionId);
const record = await ensureWorkbenchSeeded(c);
const record = await ensureWorkspaceSeeded(c);
const runtime = await getTaskSandboxRuntime(c, record);
// Skip git fetch on subsequent messages — the repo was already prepared during session
// creation. This avoids a 5-30s network round-trip to GitHub on every prompt.
@ -1234,25 +1140,25 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
.where(eq(taskRuntime.id, 1))
.run();
await syncWorkbenchSessionStatus(c, meta.sandboxSessionId, "running", Date.now());
await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "running", Date.now());
try {
await runtime.sandbox.sendPrompt({
sessionId: meta.sandboxSessionId,
prompt: prompt.join("\n\n"),
});
await syncWorkbenchSessionStatus(c, meta.sandboxSessionId, "idle", Date.now());
await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "idle", Date.now());
} catch (error) {
await updateSessionMeta(c, sessionId, {
status: "error",
errorMessage: error instanceof Error ? error.message : String(error),
});
await syncWorkbenchSessionStatus(c, meta.sandboxSessionId, "error", Date.now());
await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "error", Date.now());
throw error;
}
}
export async function stopWorkbenchSession(c: any, sessionId: string): Promise<void> {
export async function stopWorkspaceSession(c: any, sessionId: string): Promise<void> {
const meta = await requireReadySessionMeta(c, sessionId);
const sandbox = getTaskSandbox(c, c.state.organizationId, stableSandboxId(c));
await sandbox.destroySession(meta.sandboxSessionId);
@ -1262,8 +1168,8 @@ export async function stopWorkbenchSession(c: any, sessionId: string): Promise<v
await broadcastTaskUpdate(c, { sessionId });
}
export async function syncWorkbenchSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
export async function syncWorkspaceSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise<void> {
const record = await ensureWorkspaceSeeded(c);
const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await ensureSessionMeta(c, { sessionId: sessionId, sandboxSessionId: sessionId }));
let changed = false;
@ -1318,18 +1224,18 @@ export async function syncWorkbenchSessionStatus(c: any, sessionId: string, stat
}
if (changed) {
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", {
sessionId,
});
if (status !== "running") {
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_derived", {});
}
await broadcastTaskUpdate(c, { sessionId: meta.sessionId });
}
}
export async function closeWorkbenchSession(c: any, sessionId: string): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
export async function closeWorkspaceSession(c: any, sessionId: string): Promise<void> {
const record = await ensureWorkspaceSeeded(c);
const sessions = await listSessionMetaRows(c);
if (sessions.filter((candidate) => candidate.closed !== true).length <= 1) {
return;
@ -1360,7 +1266,7 @@ export async function closeWorkbenchSession(c: any, sessionId: string): Promise<
await broadcastTaskUpdate(c);
}
export async function markWorkbenchUnread(c: any): Promise<void> {
export async function markWorkspaceUnread(c: any): Promise<void> {
const sessions = await listSessionMetaRows(c);
const latest = sessions[sessions.length - 1];
if (!latest) {
@ -1372,8 +1278,8 @@ export async function markWorkbenchUnread(c: any): Promise<void> {
await broadcastTaskUpdate(c, { sessionId: latest.sessionId });
}
export async function publishWorkbenchPr(c: any): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
export async function publishWorkspacePr(c: any): Promise<void> {
const record = await ensureWorkspaceSeeded(c);
if (!record.branchName) {
throw new Error("cannot publish PR without a branch");
}
@ -1400,8 +1306,8 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
await broadcastTaskUpdate(c);
}
export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
export async function revertWorkspaceFile(c: any, path: string): Promise<void> {
const record = await ensureWorkspaceSeeded(c);
if (!record.activeSandboxId) {
throw new Error("cannot revert file without an active sandbox");
}
@ -1419,6 +1325,6 @@ export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
if (result.exitCode !== 0) {
throw new Error(`file revert failed (${result.exitCode}): ${result.result}`);
}
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_derived", {});
await broadcastTaskUpdate(c);
}

View file

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

View file

@ -10,6 +10,12 @@ const journal = {
tag: "0000_auth_user",
breakpoints: true,
},
{
idx: 1,
when: 1773532800000,
tag: "0001_user_task_state",
breakpoints: true,
},
],
} as const;
@ -58,23 +64,39 @@ CREATE TABLE \`account\` (
CREATE UNIQUE INDEX \`account_provider_account_idx\` ON \`account\` (\`provider_id\`, \`account_id\`);
--> statement-breakpoint
CREATE TABLE \`user_profiles\` (
\`user_id\` text PRIMARY KEY NOT NULL,
\`id\` integer PRIMARY KEY NOT NULL,
\`user_id\` text NOT NULL,
\`github_account_id\` text,
\`github_login\` text,
\`role_label\` text NOT NULL,
\`default_model\` text DEFAULT 'claude-sonnet-4' NOT NULL,
\`eligible_organization_ids_json\` text NOT NULL,
\`starter_repo_status\` text NOT NULL,
\`starter_repo_starred_at\` integer,
\`starter_repo_skipped_at\` integer,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
\`updated_at\` integer NOT NULL,
CONSTRAINT \`user_profiles_singleton_id_check\` CHECK(\`id\` = 1)
);
--> statement-breakpoint
CREATE UNIQUE INDEX \`user_profiles_user_id_idx\` ON \`user_profiles\` (\`user_id\`);
--> statement-breakpoint
CREATE TABLE \`session_state\` (
\`session_id\` text PRIMARY KEY NOT NULL,
\`active_organization_id\` text,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);`,
m0001: `CREATE TABLE \`user_task_state\` (
\`task_id\` text NOT NULL,
\`session_id\` text NOT NULL,
\`active_session_id\` text,
\`unread\` integer DEFAULT 0 NOT NULL,
\`draft_text\` text DEFAULT '' NOT NULL,
\`draft_attachments_json\` text DEFAULT '[]' NOT NULL,
\`draft_updated_at\` integer,
\`updated_at\` integer NOT NULL,
PRIMARY KEY(\`task_id\`, \`session_id\`)
);`,
} as const,
};

View file

@ -0,0 +1,103 @@
import { check, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
/** 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(),
});
/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */
export const authSessions = sqliteTable(
"session",
{
id: text("id").notNull().primaryKey(),
token: text("token").notNull(),
userId: text("user_id").notNull(),
expiresAt: integer("expires_at").notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
},
(table) => ({
tokenIdx: uniqueIndex("session_token_idx").on(table.token),
}),
);
/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */
export const authAccounts = sqliteTable(
"account",
{
id: text("id").notNull().primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id").notNull(),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: integer("access_token_expires_at"),
refreshTokenExpiresAt: integer("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
},
(table) => ({
providerAccountIdx: uniqueIndex("account_provider_account_idx").on(table.providerId, table.accountId),
}),
);
/** Custom Foundry table — not part of Better Auth. */
export const userProfiles = sqliteTable(
"user_profiles",
{
id: integer("id").primaryKey(),
userId: text("user_id").notNull(),
githubAccountId: text("github_account_id"),
githubLogin: text("github_login"),
roleLabel: text("role_label").notNull(),
defaultModel: text("default_model").notNull().default("claude-sonnet-4"),
eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(),
starterRepoStatus: text("starter_repo_status").notNull(),
starterRepoStarredAt: integer("starter_repo_starred_at"),
starterRepoSkippedAt: integer("starter_repo_skipped_at"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
},
(table) => ({
userIdIdx: uniqueIndex("user_profiles_user_id_idx").on(table.userId),
singletonCheck: check("user_profiles_singleton_id_check", sql`${table.id} = 1`),
}),
);
/** Custom Foundry table — not part of Better Auth. */
export const sessionState = sqliteTable("session_state", {
sessionId: text("session_id").notNull().primaryKey(),
activeOrganizationId: text("active_organization_id"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
/** Custom Foundry table — not part of Better Auth. Stores per-user task/session UI state. */
export const userTaskState = sqliteTable(
"user_task_state",
{
taskId: text("task_id").notNull(),
sessionId: text("session_id").notNull(),
activeSessionId: text("active_session_id"),
unread: integer("unread").notNull().default(0),
draftText: text("draft_text").notNull().default(""),
draftAttachmentsJson: text("draft_attachments_json").notNull().default("[]"),
draftUpdatedAt: integer("draft_updated_at"),
updatedAt: integer("updated_at").notNull(),
},
(table) => ({
pk: primaryKey({ columns: [table.taskId, table.sessionId] }),
}),
);

View file

@ -1,7 +1,7 @@
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 { authUserDb } from "./db/db.js";
import { authAccounts, authSessions, authUsers, sessionState, userProfiles } from "./db/schema.js";
import { userDb } from "./db/db.js";
import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js";
const tables = {
user: authUsers,
@ -9,12 +9,13 @@ const tables = {
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 auth user model: ${model}`);
throw new Error(`Unsupported user model: ${model}`);
}
return table as any;
}
@ -22,7 +23,7 @@ function tableFor(model: string) {
function columnFor(table: any, field: string) {
const column = table[field];
if (!column) {
throw new Error(`Unsupported auth user field: ${field}`);
throw new Error(`Unsupported user field: ${field}`);
}
return column;
}
@ -150,10 +151,10 @@ async function applyJoinToRows(c: any, model: string, rows: any[], join: any) {
return rows;
}
export const authUser = actor({
db: authUserDb,
export const user = actor({
db: userDb,
options: {
name: "Auth User",
name: "User",
icon: "shield",
actionTimeout: 60_000,
},
@ -161,6 +162,8 @@ export const authUser = 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
@ -174,6 +177,8 @@ export const authUser = actor({
.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);
@ -181,6 +186,8 @@ export const authUser = actor({
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);
@ -202,6 +209,8 @@ export const authUser = actor({
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);
@ -216,6 +225,8 @@ export const authUser = actor({
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);
@ -231,6 +242,8 @@ export const authUser = actor({
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);
@ -240,6 +253,8 @@ export const authUser = actor({
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);
@ -251,6 +266,8 @@ export const authUser = actor({
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);
@ -260,6 +277,7 @@ export const authUser = actor({
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) {
@ -280,6 +298,7 @@ export const authUser = actor({
};
},
// Custom Foundry action — not part of Better Auth.
async upsertUserProfile(
c,
input: {
@ -288,6 +307,7 @@ export const authUser = actor({
githubAccountId?: string | null;
githubLogin?: string | null;
roleLabel?: string;
defaultModel?: string;
eligibleOrganizationIdsJson?: string;
starterRepoStatus?: string;
starterRepoStarredAt?: number | null;
@ -299,10 +319,12 @@ export const authUser = actor({
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,
@ -316,6 +338,7 @@ export const authUser = actor({
...(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 } : {}),
@ -328,6 +351,7 @@ export const authUser = actor({
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
@ -349,5 +373,101 @@ export const authUser = actor({
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();
},
},
});

View file

@ -1,7 +1,7 @@
import { betterAuth } from "better-auth";
import { createAdapterFactory } from "better-auth/adapters";
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/app-shell.js";
import { authUserKey, organizationKey } from "../actors/keys.js";
import { organizationKey, userKey } from "../actors/keys.js";
import { logger } from "../logging.js";
const AUTH_BASE_PATH = "/v1/auth";
@ -75,7 +75,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
// getOrCreate is intentional here: the adapter runs during Better Auth callbacks
// which can fire before any explicit create path. The app organization and auth user
// which can fire before any explicit create path. The app organization and user
// actors must exist by the time the adapter needs them.
const appOrganization = () =>
actorClient.organization.getOrCreate(organizationKey(APP_SHELL_ORGANIZATION_ID), {
@ -83,9 +83,9 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
});
// getOrCreate is intentional: Better Auth creates user records during OAuth
// callbacks, so the auth-user actor must be lazily provisioned on first access.
const getAuthUser = async (userId: string) =>
await actorClient.authUser.getOrCreate(authUserKey(userId), {
// callbacks, so the user actor must be lazily provisioned on first access.
const getUser = async (userId: string) =>
await actorClient.user.getOrCreate(userKey(userId), {
createWithInput: { userId },
});
@ -178,7 +178,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
throw new Error(`Unable to resolve auth actor for create(${model})`);
}
const userActor = await getAuthUser(userId);
const userActor = await getUser(userId);
const created = await userActor.createAuthRecord({ model, data: transformed });
const organization = await appOrganization();
@ -220,7 +220,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return null;
}
const userActor = await getAuthUser(userId);
const userActor = await getUser(userId);
const found = await userActor.findOneAuthRecord({ model, where: transformedWhere, join });
return found ? ((await transformOutput(found, model, undefined, join)) as any) : null;
},
@ -259,7 +259,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const rows = [];
for (const [userId, tokens] of byUser) {
const userActor = await getAuthUser(userId);
const userActor = await getUser(userId);
const scopedWhere = transformedWhere.map((entry: any) =>
entry.field === "token" && entry.operator === "in" ? { ...entry, value: tokens } : entry,
);
@ -275,7 +275,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return [];
}
const userActor = await getAuthUser(userId);
const userActor = await getUser(userId);
const found = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit, sortBy, offset, join });
return await Promise.all(found.map(async (row: any) => await transformOutput(row, model, undefined, join)));
},
@ -292,7 +292,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return null;
}
const userActor = await getAuthUser(userId);
const userActor = await getUser(userId);
const before =
model === "user"
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
@ -345,7 +345,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return 0;
}
const userActor = await getAuthUser(userId);
const userActor = await getUser(userId);
return await userActor.updateManyAuthRecords({ model, where: transformedWhere, update: transformedUpdate });
},
@ -361,7 +361,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return;
}
const userActor = await getAuthUser(userId);
const userActor = await getUser(userId);
const organization = await appOrganization();
const before = await userActor.findOneAuthRecord({ model, where: transformedWhere });
await userActor.deleteAuthRecord({ model, where: transformedWhere });
@ -397,7 +397,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
if (!userId) {
return 0;
}
const userActor = await getAuthUser(userId);
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 });
@ -415,7 +415,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return 0;
}
const userActor = await getAuthUser(userId);
const userActor = await getUser(userId);
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
return deleted;
},
@ -431,7 +431,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return 0;
}
const userActor = await getAuthUser(userId);
const userActor = await getUser(userId);
return await userActor.countAuthRecords({ model, where: transformedWhere });
},
};
@ -481,12 +481,12 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
if (!route?.userId) {
return null;
}
const userActor = await getAuthUser(route.userId);
const userActor = await getUser(route.userId);
return await userActor.getAppAuthState({ sessionId });
},
async upsertUserProfile(userId: string, patch: Record<string, unknown>) {
const userActor = await getAuthUser(userId);
const userActor = await getUser(userId);
return await userActor.upsertUserProfile({ userId, patch });
},
@ -495,7 +495,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
if (!authState?.user?.id) {
throw new Error(`Unknown auth session ${sessionId}`);
}
const userActor = await getAuthUser(authState.user.id);
const userActor = await getUser(authState.user.id);
return await userActor.upsertSessionState({ sessionId, activeOrganizationId });
},

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { githubDataKey, historyKey, organizationKey, repositoryKey, taskKey, taskSandboxKey } from "../src/actors/keys.js";
import { auditLogKey, githubDataKey, organizationKey, repositoryKey, taskKey, taskSandboxKey } from "../src/actors/keys.js";
describe("actor keys", () => {
it("prefixes every key with organization namespace", () => {
@ -8,7 +8,7 @@ describe("actor keys", () => {
repositoryKey("default", "repo"),
taskKey("default", "repo", "task"),
taskSandboxKey("default", "sbx"),
historyKey("default", "repo"),
auditLogKey("default", "repo"),
githubDataKey("default"),
];

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { requireSendableSessionMeta, shouldMarkSessionUnreadForStatus, shouldRecreateSessionForModelChange } from "../src/actors/task/workbench.js";
import { requireSendableSessionMeta, shouldMarkSessionUnreadForStatus, shouldRecreateSessionForModelChange } from "../src/actors/task/workspace.js";
describe("workbench unread status transitions", () => {
describe("workspace unread status transitions", () => {
it("marks unread when a running session first becomes idle", () => {
expect(shouldMarkSessionUnreadForStatus({ thinkingSinceMs: Date.now() - 1_000 }, "idle")).toBe(true);
});
@ -15,7 +15,7 @@ describe("workbench unread status transitions", () => {
});
});
describe("workbench model changes", () => {
describe("workspace model changes", () => {
it("recreates an unused ready session so the selected model takes effect", () => {
expect(
shouldRecreateSessionForModelChange({
@ -58,9 +58,9 @@ describe("workbench model changes", () => {
});
});
describe("workbench send readiness", () => {
describe("workspace send readiness", () => {
it("rejects unknown sessions", () => {
expect(() => requireSendableSessionMeta(null, "session-1")).toThrow("Unknown workbench session: session-1");
expect(() => requireSendableSessionMeta(null, "session-1")).toThrow("Unknown workspace session: session-1");
});
it("rejects pending sessions", () => {