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

@ -73,7 +73,7 @@ Use `pnpm` workspaces and Turborepo.
- All backend interaction (actor calls, metadata/health checks, backend HTTP endpoint access) must go through the dedicated client library in `packages/client`. - All backend interaction (actor calls, metadata/health checks, backend HTTP endpoint access) must go through the dedicated client library in `packages/client`.
- Outside `packages/client`, do not call backend endpoints directly (for example `fetch(.../v1/rivet...)`), except in black-box E2E tests that intentionally exercise raw transport behavior. - Outside `packages/client`, do not call backend endpoints directly (for example `fetch(.../v1/rivet...)`), except in black-box E2E tests that intentionally exercise raw transport behavior.
- GUI state should update in realtime (no manual refresh buttons). Prefer RivetKit push reactivity and actor-driven events; do not add polling/refetch for normal product flows. - GUI state should update in realtime (no manual refresh buttons). Prefer RivetKit push reactivity and actor-driven events; do not add polling/refetch for normal product flows.
- Keep the mock workbench types and mock client in `packages/shared` + `packages/client` up to date with the frontend contract. The mock is the UI testing reference implementation while backend functionality catches up. - Keep the mock workspace types and mock client in `packages/shared` + `packages/client` up to date with the frontend contract. The mock is the UI testing reference implementation while backend functionality catches up.
- Keep frontend route/state coverage current in code and tests; there is no separate page-inventory doc to maintain. - Keep frontend route/state coverage current in code and tests; there is no separate page-inventory doc to maintain.
- If Foundry uses a shared component from `@sandbox-agent/react`, make changes in `sdks/react` instead of copying or forking that component into Foundry. - If Foundry uses a shared component from `@sandbox-agent/react`, make changes in `sdks/react` instead of copying or forking that component into Foundry.
- When changing shared React components in `sdks/react` for Foundry, verify they still work in the Sandbox Agent Inspector before finishing. - When changing shared React components in `sdks/react` for Foundry, verify they still work in the Sandbox Agent Inspector before finishing.
@ -227,8 +227,8 @@ Action handlers must return fast. The pattern:
Examples: Examples:
- `createTask``wait: true` (returns `{ taskId }`), then enqueue provisioning with `wait: false`. Client sees task appear immediately with pending status, observes `ready` via organization events. - `createTask``wait: true` (returns `{ taskId }`), then enqueue provisioning with `wait: false`. Client sees task appear immediately with pending status, observes `ready` via organization events.
- `sendWorkbenchMessage` → validate session is `ready` (throw if not), enqueue with `wait: false`. Client observes session transition to `running``idle` via session events. - `sendWorkspaceMessage` → validate session is `ready` (throw if not), enqueue with `wait: false`. Client observes session transition to `running``idle` via session events.
- `createWorkbenchSession` → `wait: true` (returns `{ tabId }`), enqueue sandbox provisioning with `wait: false`. Client observes `pending_provision``ready` via task events. - `createWorkspaceSession` → `wait: true` (returns `{ sessionId }`), enqueue sandbox provisioning with `wait: false`. Client observes `pending_provision``ready` via task events.
Never use `wait: true` for operations that depend on external readiness, sandbox I/O, agent responses, git network operations, polling loops, or long-running queue drains. Never hold an action open while waiting for an external system to become ready — that is a polling/retry loop in disguise. Never use `wait: true` for operations that depend on external readiness, sandbox I/O, agent responses, git network operations, polling loops, or long-running queue drains. Never hold an action open while waiting for an external system to become ready — that is a polling/retry loop in disguise.
@ -320,9 +320,9 @@ Each entry must include:
- Friction/issue - Friction/issue
- Attempted fix/workaround and outcome - Attempted fix/workaround and outcome
## History Events ## Audit Log Events
Log notable workflow changes to `events` so `hf history` remains complete: Log notable workflow changes to `events` so the audit log remains complete:
- create - create
- attach - attach
@ -331,6 +331,8 @@ Log notable workflow changes to `events` so `hf history` remains complete:
- status transitions - status transitions
- PR state transitions - PR state transitions
When adding new task/workspace commands, always add a corresponding audit log event.
## Validation After Changes ## Validation After Changes
Always run and fix failures: Always run and fix failures:

1343
foundry/FOUNDRY-CHANGES.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,13 +6,13 @@ Keep the backend actor tree aligned with this shape unless we explicitly decide
```text ```text
OrganizationActor OrganizationActor
├─ HistoryActor(organization-scoped global feed) ├─ AuditLogActor(organization-scoped global feed)
├─ GithubDataActor ├─ GithubDataActor
├─ RepositoryActor(repo) ├─ RepositoryActor(repo)
│ └─ TaskActor(task) │ └─ TaskActor(task)
│ ├─ TaskSessionActor(session) × N │ ├─ TaskSessionActor(session) × N
│ │ └─ SessionStatusSyncActor(session) × 0..1 │ │ └─ SessionStatusSyncActor(session) × 0..1
│ └─ Task-local workbench state │ └─ Task-local workspace state
└─ SandboxInstanceActor(sandboxProviderId, sandboxId) × N └─ SandboxInstanceActor(sandboxProviderId, sandboxId) × N
``` ```
@ -46,12 +46,12 @@ OrganizationActor (coordinator for repos + auth users)
│ └─ TaskActor (coordinator for sessions + sandboxes) │ └─ TaskActor (coordinator for sessions + sandboxes)
│ │ │ │
│ │ Index tables: │ │ Index tables:
│ │ ├─ taskWorkbenchSessions → Session index (session metadata, transcript, draft) │ │ ├─ taskWorkspaceSessions → Session index (session metadata, transcript, draft)
│ │ └─ taskSandboxes → SandboxInstanceActor index (sandbox history) │ │ └─ taskSandboxes → SandboxInstanceActor index (sandbox history)
│ │ │ │
│ └─ SandboxInstanceActor (leaf) │ └─ 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) └─ 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 ## Ownership Rules
- `OrganizationActor` is the organization coordinator and lookup/index owner. - `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. - `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` is one branch. Treat `1 task = 1 branch` once branch assignment is finalized.
- `TaskActor` can have many sessions. - `TaskActor` can have many sessions.
- `TaskActor` can reference many sandbox instances historically, but should have only one active sandbox/session at a time. - `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. - Session unread state and draft prompts are backend-owned workspace state, not frontend-local state.
- Branch rename is a real git operation, not just metadata. - Branch names are immutable after task creation. Do not implement branch-rename flows.
- `SandboxInstanceActor` stays separate from `TaskActor`; tasks/sessions reference it by identity. - `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()`. - 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. - 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. - 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. - 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 ## 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. 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. 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 ## Maintenance
- Keep this file up to date whenever actor ownership, hierarchy, or lifecycle responsibilities change. - 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 * as schema from "./schema.js";
import migrations from "./migrations.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"), taskId: text("task_id"),
branchName: text("branch_name"), branchName: text("branch_name"),
kind: text("kind").notNull(), 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(), payloadJson: text("payload_json").notNull(),
createdAt: integer("created_at").notNull(), createdAt: integer("created_at").notNull(),
}); });

View file

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

View file

@ -1,6 +1,9 @@
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", { export const githubMeta = sqliteTable(
"github_meta",
{
id: integer("id").primaryKey(), id: integer("id").primaryKey(),
connectedAccount: text("connected_account").notNull(), connectedAccount: text("connected_account").notNull(),
installationStatus: text("installation_status").notNull(), installationStatus: text("installation_status").notNull(),
@ -9,7 +12,9 @@ export const githubMeta = sqliteTable("github_meta", {
lastSyncLabel: text("last_sync_label").notNull(), lastSyncLabel: text("last_sync_label").notNull(),
lastSyncAt: integer("last_sync_at"), lastSyncAt: integer("last_sync_at"),
updatedAt: integer("updated_at").notNull(), updatedAt: integer("updated_at").notNull(),
}); },
(table) => [check("github_meta_singleton_id_check", sql`${table.id} = 1`)],
);
export const githubRepositories = sqliteTable("github_repositories", { export const githubRepositories = sqliteTable("github_repositories", {
repoId: text("repo_id").notNull().primaryKey(), 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); return await runFullSync(c, input);
}, },
async reloadOrganization(c) { async adminReloadOrganization(c) {
return await runFullSync(c, { label: "Reloading GitHub organization..." }); return await runFullSync(c, { label: "Reloading GitHub organization..." });
}, },
async reloadAllPullRequests(c) { async adminReloadAllPullRequests(c) {
return await runFullSync(c, { label: "Reloading GitHub pull requests..." }); 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); const beforeRows = await readAllPullRequestRows(c);
await c.db.delete(githubPullRequests).run(); await c.db.delete(githubPullRequests).run();
await c.db.delete(githubBranches).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) { export function actorClient(c: any) {
return c.client(); return c.client();
@ -10,14 +10,14 @@ export async function getOrCreateOrganization(c: any, organizationId: string) {
}); });
} }
export async function getOrCreateAuthUser(c: any, userId: string) { export async function getOrCreateUser(c: any, userId: string) {
return await actorClient(c).authUser.getOrCreate(authUserKey(userId), { return await actorClient(c).user.getOrCreate(userKey(userId), {
createWithInput: { userId }, createWithInput: { userId },
}); });
} }
export function getAuthUser(c: any, userId: string) { export function getUser(c: any, userId: string) {
return actorClient(c).authUser.get(authUserKey(userId)); return actorClient(c).user.get(userKey(userId));
} }
export async function getOrCreateRepository(c: any, organizationId: string, repoId: string, remoteUrl: string) { export async function getOrCreateRepository(c: any, organizationId: string, repoId: string, 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) { export async function getOrCreateAuditLog(c: any, organizationId: string, repoId: string) {
return await actorClient(c).history.getOrCreate(historyKey(organizationId, repoId), { return await actorClient(c).auditLog.getOrCreate(auditLogKey(organizationId, repoId), {
createWithInput: { createWithInput: {
organizationId, organizationId,
repoId, repoId,
@ -75,8 +75,8 @@ export async function getOrCreateTaskSandbox(c: any, organizationId: string, san
}); });
} }
export function selfHistory(c: any) { export function selfAuditLog(c: any) {
return actorClient(c).history.getForId(c.actorId); return actorClient(c).auditLog.getForId(c.actorId);
} }
export function selfTask(c: any) { export function selfTask(c: any) {
@ -91,8 +91,8 @@ export function selfRepository(c: any) {
return actorClient(c).repository.getForId(c.actorId); return actorClient(c).repository.getForId(c.actorId);
} }
export function selfAuthUser(c: any) { export function selfUser(c: any) {
return actorClient(c).authUser.getForId(c.actorId); return actorClient(c).user.getForId(c.actorId);
} }
export function selfGithubData(c: any) { 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 { setup } from "rivetkit";
import { githubData } from "./github-data/index.js"; import { githubData } from "./github-data/index.js";
import { task } from "./task/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 { repository } from "./repository/index.js";
import { taskSandbox } from "./sandbox/index.js"; import { taskSandbox } from "./sandbox/index.js";
import { organization } from "./organization/index.js"; import { organization } from "./organization/index.js";
@ -21,22 +21,22 @@ export const registry = setup({
baseLogger: logger, baseLogger: logger,
}, },
use: { use: {
authUser, user,
organization, organization,
repository, repository,
task, task,
taskSandbox, taskSandbox,
history, auditLog,
githubData, githubData,
}, },
}); });
export * from "./context.js"; export * from "./context.js";
export * from "./events.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 "./github-data/index.js";
export * from "./task/index.js"; export * from "./task/index.js";
export * from "./history/index.js";
export * from "./keys.js"; export * from "./keys.js";
export * from "./repository/index.js"; export * from "./repository/index.js";
export * from "./sandbox/index.js"; export * from "./sandbox/index.js";

View file

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

View file

@ -3,7 +3,7 @@ import { desc, eq } from "drizzle-orm";
import { Loop } from "rivetkit/workflow"; import { Loop } from "rivetkit/workflow";
import type { import type {
CreateTaskInput, CreateTaskInput,
HistoryEvent, AuditLogEvent,
HistoryQueryInput, HistoryQueryInput,
ListTasksInput, ListTasksInput,
SandboxProviderId, SandboxProviderId,
@ -14,32 +14,30 @@ import type {
SwitchResult, SwitchResult,
TaskRecord, TaskRecord,
TaskSummary, TaskSummary,
TaskWorkbenchChangeModelInput, TaskWorkspaceChangeModelInput,
TaskWorkbenchCreateTaskInput, TaskWorkspaceCreateTaskInput,
TaskWorkbenchDiffInput, TaskWorkspaceDiffInput,
TaskWorkbenchRenameInput, TaskWorkspaceRenameInput,
TaskWorkbenchRenameSessionInput, TaskWorkspaceRenameSessionInput,
TaskWorkbenchSelectInput, TaskWorkspaceSelectInput,
TaskWorkbenchSetSessionUnreadInput, TaskWorkspaceSetSessionUnreadInput,
TaskWorkbenchSendMessageInput, TaskWorkspaceSendMessageInput,
TaskWorkbenchSessionInput, TaskWorkspaceSessionInput,
TaskWorkbenchUpdateDraftInput, TaskWorkspaceUpdateDraftInput,
WorkbenchOpenPrSummary, WorkspaceRepositorySummary,
WorkbenchRepositorySummary, WorkspaceTaskSummary,
WorkbenchSessionSummary,
WorkbenchTaskSummary,
OrganizationEvent, OrganizationEvent,
OrganizationSummarySnapshot, OrganizationSummarySnapshot,
OrganizationUseInput, OrganizationUseInput,
} from "@sandbox-agent/foundry-shared"; } from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../context.js"; 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 { logActorWarning, resolveErrorMessage } from "../logging.js";
import { defaultSandboxProviderId } from "../../sandbox-config.js"; import { defaultSandboxProviderId } from "../../sandbox-config.js";
import { repoIdFromRemote } from "../../services/repo.js"; import { repoIdFromRemote } from "../../services/repo.js";
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js"; import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
import { organizationProfile, taskLookup, repos, taskSummaries } from "./db/schema.js"; import { organizationProfile, repos } from "./db/schema.js";
import { agentTypeForModel } from "../task/workbench.js"; import { agentTypeForModel } from "../task/workspace.js";
import { expectQueueResponse } from "../../services/queue.js"; import { expectQueueResponse } from "../../services/queue.js";
import { organizationAppActions } from "./app-shell.js"; import { organizationAppActions } from "./app-shell.js";
@ -49,6 +47,7 @@ interface OrganizationState {
interface GetTaskInput { interface GetTaskInput {
organizationId: string; organizationId: string;
repoId?: string;
taskId: string; taskId: string;
} }
@ -72,7 +71,7 @@ export function organizationWorkflowQueueName(name: OrganizationQueueName): Orga
return name; return name;
} }
const ORGANIZATION_PROFILE_ROW_ID = "profile"; const ORGANIZATION_PROFILE_ROW_ID = 1;
function assertOrganization(c: { state: OrganizationState }, organizationId: string): void { function assertOrganization(c: { state: OrganizationState }, organizationId: string): void {
if (organizationId !== c.state.organizationId) { 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[]> { async function collectAllTaskSummaries(c: any): Promise<TaskSummary[]> {
const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all(); const 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; 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 repoTasks = taskRows.filter((task) => task.repoId === repoRow.repoId);
const latestActivityMs = repoTasks.reduce((latest, task) => Math.max(latest, task.updatedAtMs), repoRow.updatedAt); 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) { async function resolveRepositoryForTask(c: any, taskId: string, repoId?: string | null) {
return { if (repoId) {
taskId: taskSummary.id, const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get();
repoId: taskSummary.repoId, if (!repoRow) {
title: taskSummary.title, throw new Error(`Unknown repo: ${repoId}`);
status: taskSummary.status, }
repoName: taskSummary.repoName, const repository = await getOrCreateRepository(c, c.state.organizationId, repoId, repoRow.remoteUrl);
updatedAtMs: taskSummary.updatedAtMs, return { repoId, repository };
branch: taskSummary.branch, }
pullRequestJson: JSON.stringify(taskSummary.pullRequest),
sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary), 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 { async function reconcileWorkspaceProjection(c: any): Promise<OrganizationSummarySnapshot> {
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> {
const repoRows = await c.db const repoRows = await c.db
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt }) .select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })
.from(repos) .from(repos)
.orderBy(desc(repos.updatedAt)) .orderBy(desc(repos.updatedAt))
.all(); .all();
const taskRows: WorkbenchTaskSummary[] = []; const taskRows: WorkspaceTaskSummary[] = [];
for (const row of repoRows) { for (const row of repoRows) {
try { try {
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl); const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl);
const summaries = await repository.listTaskSummaries({ includeArchived: true }); taskRows.push(...(await repository.listWorkspaceTaskSummaries({})));
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) { } catch (error) {
logActorWarning("organization", "failed collecting task summary during reconciliation", { logActorWarning("organization", "failed collecting repo during workspace reconciliation", {
organizationId: c.state.organizationId,
repoId: row.repoId,
taskId: summary.taskId,
error: resolveErrorMessage(error),
});
}
}
} catch (error) {
logActorWarning("organization", "failed collecting repo during workbench reconciliation", {
organizationId: c.state.organizationId, organizationId: c.state.organizationId,
repoId: row.repoId, repoId: row.repoId,
error: resolveErrorMessage(error), error: resolveErrorMessage(error),
@ -249,19 +175,17 @@ async function reconcileWorkbenchProjection(c: any): Promise<OrganizationSummary
organizationId: c.state.organizationId, organizationId: c.state.organizationId,
repos: repoRows.map((row) => buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs), repos: repoRows.map((row) => buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
taskSummaries: taskRows, taskSummaries: taskRows,
openPullRequests: await listOpenPullRequestsSnapshot(c, taskRows),
}; };
} }
async function requireWorkbenchTask(c: any, taskId: string) { async function requireWorkspaceTask(c: any, repoId: string, taskId: string) {
const repoId = await resolveRepoId(c, taskId); return getTaskHandle(c, c.state.organizationId, repoId, taskId);
return getTask(c, c.state.organizationId, repoId, taskId);
} }
/** /**
* Reads the organization sidebar snapshot from the organization actor's local SQLite * Reads the organization sidebar snapshot by fanning out one level to the
* plus the org-scoped GitHub actor for open PRs. Task actors still push * repository coordinators. Task summaries are repository-owned; organization
* summary updates into `task_summaries`, so the hot read path stays bounded. * only aggregates them.
*/ */
async function getOrganizationSummarySnapshot(c: any): Promise<OrganizationSummarySnapshot> { async function getOrganizationSummarySnapshot(c: any): Promise<OrganizationSummarySnapshot> {
const repoRows = await c.db const repoRows = await c.db
@ -273,25 +197,33 @@ async function getOrganizationSummarySnapshot(c: any): Promise<OrganizationSumma
.from(repos) .from(repos)
.orderBy(desc(repos.updatedAt)) .orderBy(desc(repos.updatedAt))
.all(); .all();
const taskRows = await c.db.select().from(taskSummaries).orderBy(desc(taskSummaries.updatedAtMs)).all(); const summaries: WorkspaceTaskSummary[] = [];
const summaries = taskRows.map(taskSummaryFromRow); 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 { return {
organizationId: c.state.organizationId, organizationId: c.state.organizationId,
repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs), repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
taskSummaries: summaries, taskSummaries: summaries,
openPullRequests: await listOpenPullRequestsSnapshot(c, summaries),
}; };
} }
async function broadcastRepoSummary( async function broadcastOrganizationSnapshot(c: any): Promise<void> {
c: any, c.broadcast("organizationUpdated", {
type: "repoAdded" | "repoUpdated", type: "organizationUpdated",
repoRow: { repoId: string; remoteUrl: string; updatedAt: number }, snapshot: await getOrganizationSummarySnapshot(c),
): Promise<void> { } satisfies OrganizationEvent);
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 createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskRecord> { 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, 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; return created;
} }
@ -451,67 +357,8 @@ export const organizationActions = {
}; };
}, },
/** async refreshOrganizationSnapshot(c: any): Promise<void> {
* Called by task actors when their summary-level state changes. await broadcastOrganizationSnapshot(c);
* 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 applyGithubRepositoryProjection(c: any, input: { repoId: string; remoteUrl: string }): Promise<void> { async applyGithubRepositoryProjection(c: any, input: { repoId: string; remoteUrl: string }): Promise<void> {
@ -533,11 +380,7 @@ export const organizationActions = {
}, },
}) })
.run(); .run();
await broadcastRepoSummary(c, existing ? "repoUpdated" : "repoAdded", { await broadcastOrganizationSnapshot(c);
repoId: input.repoId,
remoteUrl: input.remoteUrl,
updatedAt: now,
});
}, },
async applyGithubDataProjection( async applyGithubDataProjection(
@ -576,11 +419,7 @@ export const organizationActions = {
}, },
}) })
.run(); .run();
await broadcastRepoSummary(c, existingById.has(repoId) ? "repoUpdated" : "repoAdded", { await broadcastOrganizationSnapshot(c);
repoId,
remoteUrl: repository.cloneUrl,
updatedAt: now,
});
} }
for (const repo of existingRepos) { for (const repo of existingRepos) {
@ -588,7 +427,7 @@ export const organizationActions = {
continue; continue;
} }
await c.db.delete(repos).where(eq(repos.repoId, repo.repoId)).run(); 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 const profile = await c.db
@ -648,12 +487,12 @@ export const organizationActions = {
return await getOrganizationSummarySnapshot(c); return await getOrganizationSummarySnapshot(c);
}, },
async reconcileWorkbenchState(c: any, input: OrganizationUseInput): Promise<OrganizationSummarySnapshot> { async adminReconcileWorkspaceState(c: any, input: OrganizationUseInput): Promise<OrganizationSummarySnapshot> {
assertOrganization(c, input.organizationId); 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). // Step 1: Create the task record (wait: true — local state mutations only).
const created = await organizationActions.createTask(c, { const created = await organizationActions.createTask(c, {
organizationId: c.state.organizationId, organizationId: c.state.organizationId,
@ -668,8 +507,8 @@ export const organizationActions = {
// The task workflow creates the session record and sends the message in // The task workflow creates the session record and sends the message in
// the background. The client observes progress via push events on the // the background. The client observes progress via push events on the
// task subscription topic. // task subscription topic.
const task = await requireWorkbenchTask(c, created.taskId); const task = await requireWorkspaceTask(c, input.repoId, created.taskId);
await task.createWorkbenchSessionAndSend({ await task.createWorkspaceSessionAndSend({
model: input.model, model: input.model,
text: input.task, text: input.task,
}); });
@ -677,84 +516,79 @@ export const organizationActions = {
return { taskId: created.taskId }; return { taskId: created.taskId };
}, },
async markWorkbenchUnread(c: any, input: TaskWorkbenchSelectInput): Promise<void> { async markWorkspaceUnread(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId); const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.markWorkbenchUnread({}); await task.markWorkspaceUnread({});
}, },
async renameWorkbenchTask(c: any, input: TaskWorkbenchRenameInput): Promise<void> { async renameWorkspaceTask(c: any, input: TaskWorkspaceRenameInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId); const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.renameWorkbenchTask(input); await task.renameWorkspaceTask(input);
}, },
async renameWorkbenchBranch(c: any, input: TaskWorkbenchRenameInput): Promise<void> { async createWorkspaceSession(c: any, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
const task = await requireWorkbenchTask(c, input.taskId); const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.renameWorkbenchBranch(input); return await task.createWorkspaceSession({ ...(input.model ? { model: input.model } : {}) });
}, },
async createWorkbenchSession(c: any, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }> { async renameWorkspaceSession(c: any, input: TaskWorkspaceRenameSessionInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId); const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
return await task.createWorkbenchSession({ ...(input.model ? { model: input.model } : {}) }); await task.renameWorkspaceSession(input);
}, },
async renameWorkbenchSession(c: any, input: TaskWorkbenchRenameSessionInput): Promise<void> { async setWorkspaceSessionUnread(c: any, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId); const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.renameWorkbenchSession(input); await task.setWorkspaceSessionUnread(input);
}, },
async setWorkbenchSessionUnread(c: any, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> { async updateWorkspaceDraft(c: any, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId); const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.setWorkbenchSessionUnread(input); await task.updateWorkspaceDraft(input);
}, },
async updateWorkbenchDraft(c: any, input: TaskWorkbenchUpdateDraftInput): Promise<void> { async changeWorkspaceModel(c: any, input: TaskWorkspaceChangeModelInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId); const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.updateWorkbenchDraft(input); await task.changeWorkspaceModel(input);
}, },
async changeWorkbenchModel(c: any, input: TaskWorkbenchChangeModelInput): Promise<void> { async sendWorkspaceMessage(c: any, input: TaskWorkspaceSendMessageInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId); const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.changeWorkbenchModel(input); await task.sendWorkspaceMessage(input);
}, },
async sendWorkbenchMessage(c: any, input: TaskWorkbenchSendMessageInput): Promise<void> { async stopWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId); const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.sendWorkbenchMessage(input); await task.stopWorkspaceSession(input);
}, },
async stopWorkbenchSession(c: any, input: TaskWorkbenchSessionInput): Promise<void> { async closeWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId); const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.stopWorkbenchSession(input); await task.closeWorkspaceSession(input);
}, },
async closeWorkbenchSession(c: any, input: TaskWorkbenchSessionInput): Promise<void> { async publishWorkspacePr(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId); const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.closeWorkbenchSession(input); await task.publishWorkspacePr({});
}, },
async publishWorkbenchPr(c: any, input: TaskWorkbenchSelectInput): Promise<void> { async revertWorkspaceFile(c: any, input: TaskWorkspaceDiffInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId); const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.publishWorkbenchPr({}); await task.revertWorkspaceFile(input);
}, },
async revertWorkbenchFile(c: any, input: TaskWorkbenchDiffInput): Promise<void> { async adminReloadGithubOrganization(c: any): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId); await getOrCreateGithubData(c, c.state.organizationId).adminReloadOrganization({});
await task.revertWorkbenchFile(input);
}, },
async reloadGithubOrganization(c: any): Promise<void> { async adminReloadGithubPullRequests(c: any): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).reloadOrganization({}); await getOrCreateGithubData(c, c.state.organizationId).adminReloadAllPullRequests({});
}, },
async reloadGithubPullRequests(c: any): Promise<void> { async adminReloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).reloadAllPullRequests({});
},
async reloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).reloadRepository(input); 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); await getOrCreateGithubData(c, c.state.organizationId).reloadPullRequest(input);
}, },
@ -786,39 +620,39 @@ export const organizationActions = {
return await repository.getRepoOverview({}); return await repository.getRepoOverview({});
}, },
async switchTask(c: any, taskId: string): Promise<SwitchResult> { async switchTask(c: any, input: { repoId?: string; taskId: string }): Promise<SwitchResult> {
const repoId = await resolveRepoId(c, taskId); const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTask(c, c.state.organizationId, repoId, taskId); const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
const record = await h.get(); const record = await h.get();
const switched = await h.switch(); const switched = await h.switch();
return { return {
organizationId: c.state.organizationId, organizationId: c.state.organizationId,
taskId, taskId: input.taskId,
sandboxProviderId: record.sandboxProviderId, sandboxProviderId: record.sandboxProviderId,
switchTarget: switched.switchTarget, switchTarget: switched.switchTarget,
}; };
}, },
async history(c: any, input: HistoryQueryInput): Promise<HistoryEvent[]> { async auditLog(c: any, input: HistoryQueryInput): Promise<AuditLogEvent[]> {
assertOrganization(c, input.organizationId); assertOrganization(c, input.organizationId);
const limit = input.limit ?? 20; const limit = input.limit ?? 20;
const repoRows = await c.db.select({ repoId: repos.repoId }).from(repos).all(); const repoRows = await c.db.select({ repoId: repos.repoId }).from(repos).all();
const allEvents: HistoryEvent[] = []; const allEvents: AuditLogEvent[] = [];
for (const row of repoRows) { for (const row of repoRows) {
try { try {
const hist = await getOrCreateHistory(c, c.state.organizationId, row.repoId); const auditLog = await getOrCreateAuditLog(c, c.state.organizationId, row.repoId);
const items = await hist.list({ const items = await auditLog.list({
branch: input.branch, branch: input.branch,
taskId: input.taskId, taskId: input.taskId,
limit, limit,
}); });
allEvents.push(...items); allEvents.push(...items);
} catch (error) { } catch (error) {
logActorWarning("organization", "history lookup failed for repo", { logActorWarning("organization", "audit log lookup failed for repo", {
organizationId: c.state.organizationId, organizationId: c.state.organizationId,
repoId: row.repoId, repoId: row.repoId,
error: resolveErrorMessage(error), error: resolveErrorMessage(error),
@ -832,57 +666,49 @@ export const organizationActions = {
async getTask(c: any, input: GetTaskInput): Promise<TaskRecord> { async getTask(c: any, input: GetTaskInput): Promise<TaskRecord> {
assertOrganization(c, input.organizationId); assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const repoId = await resolveRepoId(c, input.taskId); return await getTaskHandle(c, c.state.organizationId, repoId, input.taskId).get();
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 });
}, },
async attachTask(c: any, input: TaskProxyActionInput): Promise<{ target: string; sessionId: string | null }> { async attachTask(c: any, input: TaskProxyActionInput): Promise<{ target: string; sessionId: string | null }> {
assertOrganization(c, input.organizationId); assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId); const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId); const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
return await h.attach({ reason: input.reason }); return await h.attach({ reason: input.reason });
}, },
async pushTask(c: any, input: TaskProxyActionInput): Promise<void> { async pushTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId); assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId); const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId); const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.push({ reason: input.reason }); await h.push({ reason: input.reason });
}, },
async syncTask(c: any, input: TaskProxyActionInput): Promise<void> { async syncTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId); assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId); const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId); const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.sync({ reason: input.reason }); await h.sync({ reason: input.reason });
}, },
async mergeTask(c: any, input: TaskProxyActionInput): Promise<void> { async mergeTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId); assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId); const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId); const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.merge({ reason: input.reason }); await h.merge({ reason: input.reason });
}, },
async archiveTask(c: any, input: TaskProxyActionInput): Promise<void> { async archiveTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId); assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId); const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId); const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.archive({ reason: input.reason }); await h.archive({ reason: input.reason });
}, },
async killTask(c: any, input: TaskProxyActionInput): Promise<void> { async killTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId); assertOrganization(c, input.organizationId);
const repoId = await resolveRepoId(c, input.taskId); const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTask(c, c.state.organizationId, repoId, input.taskId); const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.kill({ reason: input.reason }); await h.kill({ reason: input.reason });
}, },
}; };

View file

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

View file

@ -10,24 +10,6 @@ const journal = {
tag: "0000_melted_viper", tag: "0000_melted_viper",
breakpoints: true, 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; } as const;
@ -73,7 +55,7 @@ CREATE TABLE \`organization_members\` (
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE \`organization_profile\` ( CREATE TABLE \`organization_profile\` (
\`id\` text PRIMARY KEY NOT NULL, \`id\` integer PRIMARY KEY NOT NULL,
\`kind\` text NOT NULL, \`kind\` text NOT NULL,
\`github_account_id\` text NOT NULL, \`github_account_id\` text NOT NULL,
\`github_login\` text NOT NULL, \`github_login\` text NOT NULL,
@ -81,7 +63,6 @@ CREATE TABLE \`organization_profile\` (
\`display_name\` text NOT NULL, \`display_name\` text NOT NULL,
\`slug\` text NOT NULL, \`slug\` text NOT NULL,
\`primary_domain\` text NOT NULL, \`primary_domain\` text NOT NULL,
\`default_model\` text NOT NULL,
\`auto_import_repos\` integer NOT NULL, \`auto_import_repos\` integer NOT NULL,
\`repo_import_status\` text NOT NULL, \`repo_import_status\` text NOT NULL,
\`github_connected_account\` text NOT NULL, \`github_connected_account\` text NOT NULL,
@ -102,7 +83,8 @@ CREATE TABLE \`organization_profile\` (
\`billing_renewal_at\` text, \`billing_renewal_at\` text,
\`billing_payment_method_label\` text NOT NULL, \`billing_payment_method_label\` text NOT NULL,
\`created_at\` integer 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 --> statement-breakpoint
CREATE TABLE \`repos\` ( CREATE TABLE \`repos\` (
@ -122,56 +104,6 @@ CREATE TABLE \`stripe_lookup\` (
\`organization_id\` text NOT NULL, \`organization_id\` text NOT NULL,
\`updated_at\` integer 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, } 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. // SQLite is per organization actor instance, so no organizationId column needed.
@ -14,36 +15,10 @@ export const repos = sqliteTable("repos", {
updatedAt: integer("updated_at").notNull(), updatedAt: integer("updated_at").notNull(),
}); });
/** export const organizationProfile = sqliteTable(
* Coordinator index of TaskActor instances. "organization_profile",
* Fast taskId repoId lookup so the organization can route requests {
* to the correct RepositoryActor without scanning all repos. id: integer("id").primaryKey(),
*/
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(), kind: text("kind").notNull(),
githubAccountId: text("github_account_id").notNull(), githubAccountId: text("github_account_id").notNull(),
githubLogin: text("github_login").notNull(), githubLogin: text("github_login").notNull(),
@ -51,7 +26,6 @@ export const organizationProfile = sqliteTable("organization_profile", {
displayName: text("display_name").notNull(), displayName: text("display_name").notNull(),
slug: text("slug").notNull(), slug: text("slug").notNull(),
primaryDomain: text("primary_domain").notNull(), primaryDomain: text("primary_domain").notNull(),
defaultModel: text("default_model").notNull(),
autoImportRepos: integer("auto_import_repos").notNull(), autoImportRepos: integer("auto_import_repos").notNull(),
repoImportStatus: text("repo_import_status").notNull(), repoImportStatus: text("repo_import_status").notNull(),
githubConnectedAccount: text("github_connected_account").notNull(), githubConnectedAccount: text("github_connected_account").notNull(),
@ -73,7 +47,9 @@ export const organizationProfile = sqliteTable("organization_profile", {
billingPaymentMethodLabel: text("billing_payment_method_label").notNull(), billingPaymentMethodLabel: text("billing_payment_method_label").notNull(),
createdAt: integer("created_at").notNull(), createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(), updatedAt: integer("updated_at").notNull(),
}); },
(table) => [check("organization_profile_singleton_id_check", sql`${table.id} = 1`)],
);
export const organizationMembers = sqliteTable("organization_members", { export const organizationMembers = sqliteTable("organization_members", {
id: text("id").notNull().primaryKey(), id: text("id").notNull().primaryKey(),
@ -133,6 +109,7 @@ export const authAccountIndex = sqliteTable("auth_account_index", {
updatedAt: integer("updated_at").notNull(), 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", { export const authVerification = sqliteTable("auth_verification", {
id: text("id").notNull().primaryKey(), id: text("id").notNull().primaryKey(),
identifier: text("identifier").notNull(), identifier: text("identifier").notNull(),

View file

@ -2,12 +2,21 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { and, desc, eq, isNotNull, ne } from "drizzle-orm"; import { and, desc, eq, isNotNull, ne } from "drizzle-orm";
import { Loop } from "rivetkit/workflow"; import { Loop } from "rivetkit/workflow";
import type { AgentType, RepoOverview, SandboxProviderId, TaskRecord, TaskSummary } from "@sandbox-agent/foundry-shared"; import type {
import { getGithubData, getOrCreateHistory, getOrCreateTask, getTask, selfRepository } from "../handles.js"; 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 { deriveFallbackTitle, resolveCreateFlowDecision } from "../../services/create-flow.js";
import { expectQueueResponse } from "../../services/queue.js"; import { expectQueueResponse } from "../../services/queue.js";
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.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 { interface CreateTaskCommand {
task: string; task: string;
@ -29,10 +38,6 @@ interface ListTaskSummariesCommand {
includeArchived?: boolean; includeArchived?: boolean;
} }
interface GetTaskEnrichedCommand {
taskId: string;
}
interface GetPullRequestForBranchCommand { interface GetPullRequestForBranchCommand {
branchName: string; branchName: string;
} }
@ -52,6 +57,61 @@ function isStaleTaskReferenceError(error: unknown): boolean {
return isActorNotFoundError(error) || message.startsWith("Task not found:"); 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> { async function persistRemoteUrl(c: any, remoteUrl: string): Promise<void> {
c.state.remoteUrl = remoteUrl; c.state.remoteUrl = remoteUrl;
await c.db 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); 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) { async function resolveGitHubRepository(c: any) {
const githubData = getGithubData(c, c.state.organizationId); const githubData = getGithubData(c, c.state.organizationId);
return await githubData.getRepository({ repoId: c.state.repoId }).catch(() => null); 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(() => []); 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> { async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
const organizationId = c.state.organizationId; const organizationId = c.state.organizationId;
const repoId = c.state.repoId; 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 created = await taskHandle.initialize({ sandboxProviderId: cmd.sandboxProviderId });
const history = await getOrCreateHistory(c, organizationId, repoId); try {
await history.append({ await upsertTaskSummary(c, await taskHandle.getTaskSummary({}));
await notifyOrganizationSnapshotChanged(c);
} catch (error) {
logActorWarning("repository", "failed seeding task summary after task creation", {
organizationId,
repoId,
taskId,
error: resolveErrorMessage(error),
});
}
const auditLog = await getOrCreateAuditLog(c, organizationId, repoId);
await auditLog.send(
"auditLog.command.append",
{
kind: "task.created", kind: "task.created",
taskId, taskId,
payload: { payload: {
repoId, repoId,
sandboxProviderId: cmd.sandboxProviderId, 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; 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 }> { async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> {
const branchName = cmd.branchName.trim(); const branchName = cmd.branchName.trim();
if (!branchName) { if (!branchName) {
@ -289,40 +402,23 @@ async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand
} }
async function listTaskSummaries(c: any, includeArchived = false): Promise<TaskSummary[]> { 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 rows = await c.db.select().from(tasks).orderBy(desc(tasks.updatedAtMs)).all();
const records: TaskSummary[] = []; return rows
.map((row) => ({
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, organizationId: c.state.organizationId,
repoId: c.state.repoId, repoId: c.state.repoId,
taskId: row.taskId, taskId: row.taskId,
error: resolveErrorMessage(error), branchName: row.branch ?? null,
}); title: row.title,
} status: row.status,
} updatedAt: row.updatedAtMs,
}))
.filter((row) => includeArchived || row.status !== "archived");
}
records.sort((a, b) => b.updatedAt - a.updatedAt); async function listWorkspaceTaskSummaries(c: any): Promise<WorkspaceTaskSummary[]> {
return records; const rows = await c.db.select().from(tasks).orderBy(desc(tasks.updatedAtMs)).all();
return rows.map(workspaceTaskSummaryFromRow);
} }
function sortOverviewBranches( function sortOverviewBranches(
@ -415,38 +511,12 @@ export const repositoryActions = {
return await listKnownTaskBranches(c); 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[]> { async listTaskSummaries(c: any, cmd?: ListTaskSummariesCommand): Promise<TaskSummary[]> {
return await listTaskSummaries(c, cmd?.includeArchived === true); return await listTaskSummaries(c, cmd?.includeArchived === true);
}, },
async getTaskEnriched(c: any, cmd: GetTaskEnrichedCommand): Promise<TaskRecord> { async listWorkspaceTaskSummaries(c: any): Promise<WorkspaceTaskSummary[]> {
const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.taskId, cmd.taskId)).get(); return await listWorkspaceTaskSummaries(c);
if (!row) {
const record = await getTask(c, c.state.organizationId, c.state.repoId, cmd.taskId).get();
await reinsertTaskIndexRow(c, cmd.taskId, record.branchName ?? null, record.updatedAt ?? Date.now());
return await enrichTaskRecord(c, record);
}
try {
const record = await getTask(c, c.state.organizationId, c.state.repoId, cmd.taskId).get();
return await enrichTaskRecord(c, record);
} catch (error) {
if (isStaleTaskReferenceError(error)) {
await deleteStaleTaskIndexRow(c, cmd.taskId);
throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`);
}
throw error;
}
}, },
async getRepositoryMetadata(c: any): Promise<{ defaultBranch: string | null; fullName: string | null; remoteUrl: string }> { 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 prRows = await githubData.listPullRequestsForRepository({ repoId: c.state.repoId }).catch(() => []);
const prByBranch = new Map(prRows.map((row) => [row.headRefName, row])); const prByBranch = new Map(prRows.map((row) => [row.headRefName, row]));
const taskRows = await c.db const taskRows = await c.db.select().from(tasks).all();
.select({
taskId: taskIndex.taskId,
branchName: taskIndex.branchName,
updatedAt: taskIndex.updatedAt,
})
.from(taskIndex)
.all();
const taskMetaByBranch = new Map<string, { taskId: string; title: string | null; status: TaskRecord["status"] | null; updatedAt: number }>(); const taskMetaByBranch = new Map<
string,
{ taskId: string; title: string | null; status: TaskRecord["status"] | null; updatedAt: number; pullRequest: WorkspacePullRequestSummary | null }
>();
for (const row of taskRows) { for (const row of taskRows) {
if (!row.branchName) { if (!row.branch) {
continue; continue;
} }
try { taskMetaByBranch.set(row.branch, {
const record = await getTask(c, c.state.organizationId, c.state.repoId, row.taskId).get();
taskMetaByBranch.set(row.branchName, {
taskId: row.taskId, taskId: row.taskId,
title: record.title ?? null, title: row.title ?? null,
status: record.status, status: row.status,
updatedAt: record.updatedAt, updatedAt: row.updatedAtMs,
pullRequest: parseJsonValue<WorkspacePullRequestSummary | null>(row.pullRequestJson, null),
}); });
} catch (error) {
if (isStaleTaskReferenceError(error)) {
await deleteStaleTaskIndexRow(c, row.taskId);
continue;
}
}
} }
const branchMap = new Map<string, { branchName: string; commitSha: string }>(); const branchMap = new Map<string, { branchName: string; commitSha: string }>();
@ -514,7 +573,7 @@ export const repositoryActions = {
const branches = sortOverviewBranches( const branches = sortOverviewBranches(
[...branchMap.values()].map((branch) => { [...branchMap.values()].map((branch) => {
const taskMeta = taskMetaByBranch.get(branch.branchName); const taskMeta = taskMetaByBranch.get(branch.branchName);
const pr = prByBranch.get(branch.branchName); const pr = taskMeta?.pullRequest ?? prByBranch.get(branch.branchName) ?? null;
return { return {
branchName: branch.branchName, branchName: branch.branchName,
commitSha: branch.commitSha, commitSha: branch.commitSha,
@ -522,10 +581,10 @@ export const repositoryActions = {
taskTitle: taskMeta?.title ?? null, taskTitle: taskMeta?.title ?? null,
taskStatus: taskMeta?.status ?? null, taskStatus: taskMeta?.status ?? null,
prNumber: pr?.number ?? null, prNumber: pr?.number ?? null,
prState: pr?.state ?? null, prState: "state" in (pr ?? {}) ? pr.state : null,
prUrl: pr?.url ?? null, prUrl: "url" in (pr ?? {}) ? pr.url : null,
ciStatus: null, ciStatus: null,
reviewStatus: null, reviewStatus: pr && "isDraft" in pr ? (pr.isDraft ? "draft" : "ready") : null,
reviewer: pr?.authorLogin ?? null, reviewer: pr?.authorLogin ?? null,
updatedAt: Math.max(taskMeta?.updatedAt ?? 0, pr?.updatedAtMs ?? 0, now), 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(); const branchName = cmd.branchName?.trim();
if (!branchName) { if (!branchName) {
return null; return null;
} }
const githubData = getGithubData(c, c.state.organizationId); const githubData = getGithubData(c, c.state.organizationId);
return await githubData.getPullRequestForBranch({ const rows = await githubData.listPullRequestsForRepository({
repoId: c.state.repoId, 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", tag: "0000_useful_la_nuit",
breakpoints: true, breakpoints: true,
}, },
{
idx: 1,
when: 1778900000000,
tag: "0001_remove_local_git_state",
breakpoints: true,
},
], ],
} as const; } as const;
@ -23,21 +17,30 @@ export default {
journal, journal,
migrations: { migrations: {
m0000: `CREATE TABLE \`repo_meta\` ( m0000: `CREATE TABLE \`repo_meta\` (
\t\`id\` integer PRIMARY KEY NOT NULL, \`id\` integer PRIMARY KEY NOT NULL,
\t\`remote_url\` text NOT NULL, \`remote_url\` text NOT NULL,
\t\`updated_at\` integer NOT NULL \`updated_at\` integer NOT NULL,
CONSTRAINT \`repo_meta_singleton_id_check\` CHECK(\`id\` = 1)
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE \`task_index\` ( CREATE TABLE \`task_index\` (
\t\`task_id\` text PRIMARY KEY NOT NULL, \`task_id\` text PRIMARY KEY NOT NULL,
\t\`branch_name\` text, \`branch_name\` text,
\t\`created_at\` integer NOT NULL, \`created_at\` integer NOT NULL,
\t\`updated_at\` integer NOT NULL \`updated_at\` integer NOT NULL
); );
`,
m0001: `DROP TABLE IF EXISTS \`branches\`;
--> statement-breakpoint --> 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, } 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). // SQLite is per repository actor instance (organizationId+repoId).
export const repoMeta = sqliteTable("repo_meta", { export const repoMeta = sqliteTable(
"repo_meta",
{
id: integer("id").primaryKey(), id: integer("id").primaryKey(),
remoteUrl: text("remote_url").notNull(), remoteUrl: text("remote_url").notNull(),
updatedAt: integer("updated_at").notNull(), updatedAt: integer("updated_at").notNull(),
}); },
(table) => [check("repo_meta_singleton_id_check", sql`${table.id} = 1`)],
);
/** /**
* Coordinator index of TaskActor instances. * Coordinator index of TaskActor instances.
* The repository actor is the coordinator for tasks. Each row maps a * The repository actor is the coordinator for tasks. Each row maps a
* taskId to its branch name. Used for branch conflict checking and * taskId to its immutable branch name. Used for branch conflict checking
* task-by-branch lookups. Rows are inserted at task creation and * and task-by-branch lookups. Rows are inserted at task creation.
* updated on branch rename.
*/ */
export const taskIndex = sqliteTable("task_index", { export const taskIndex = sqliteTable("task_index", {
taskId: text("task_id").notNull().primaryKey(), taskId: text("task_id").notNull().primaryKey(),
@ -21,3 +25,35 @@ export const taskIndex = sqliteTable("task_index", {
createdAt: integer("created_at").notNull(), createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_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, `branch_name` text,
`title` text, `title` text,
`task` text NOT NULL, `task` text NOT NULL,
`provider_id` text NOT NULL, `sandbox_provider_id` text NOT NULL,
`status` text NOT NULL, `status` text NOT NULL,
`agent_type` text DEFAULT 'claude', `agent_type` text DEFAULT 'claude',
`pr_submitted` integer DEFAULT 0, `pr_submitted` integer DEFAULT 0,
@ -19,13 +19,17 @@ CREATE TABLE `task_runtime` (
`active_switch_target` text, `active_switch_target` text,
`active_cwd` text, `active_cwd` text,
`status_message` 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, `updated_at` integer NOT NULL,
CONSTRAINT "task_runtime_singleton_id_check" CHECK("task_runtime"."id" = 1) CONSTRAINT "task_runtime_singleton_id_check" CHECK("task_runtime"."id" = 1)
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `task_sandboxes` ( CREATE TABLE `task_sandboxes` (
`sandbox_id` text PRIMARY KEY NOT NULL, `sandbox_id` text PRIMARY KEY NOT NULL,
`provider_id` text NOT NULL, `sandbox_provider_id` text NOT NULL,
`sandbox_actor_id` text, `sandbox_actor_id` text,
`switch_target` text NOT NULL, `switch_target` text NOT NULL,
`cwd` text, `cwd` text,
@ -34,10 +38,15 @@ CREATE TABLE `task_sandboxes` (
`updated_at` integer NOT NULL `updated_at` integer NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `task_workbench_sessions` ( CREATE TABLE `task_workspace_sessions` (
`session_id` text PRIMARY KEY NOT NULL, `session_id` text PRIMARY KEY NOT NULL,
`sandbox_session_id` text,
`session_name` text NOT NULL, `session_name` text NOT NULL,
`model` 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, `unread` integer DEFAULT 0 NOT NULL,
`draft_text` text DEFAULT '' NOT NULL, `draft_text` text DEFAULT '' NOT NULL,
`draft_attachments_json` text DEFAULT '[]' NOT NULL, `draft_attachments_json` text DEFAULT '[]' NOT NULL,

View file

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

View file

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

View file

@ -11,8 +11,6 @@ export const task = sqliteTable(
task: text("task").notNull(), task: text("task").notNull(),
sandboxProviderId: text("sandbox_provider_id").notNull(), sandboxProviderId: text("sandbox_provider_id").notNull(),
status: text("status").notNull(), status: text("status").notNull(),
agentType: text("agent_type").default("claude"),
prSubmitted: integer("pr_submitted").default(0),
createdAt: integer("created_at").notNull(), createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(), updatedAt: integer("updated_at").notNull(),
}, },
@ -24,14 +22,10 @@ export const taskRuntime = sqliteTable(
{ {
id: integer("id").primaryKey(), id: integer("id").primaryKey(),
activeSandboxId: text("active_sandbox_id"), activeSandboxId: text("active_sandbox_id"),
activeSessionId: text("active_session_id"),
activeSwitchTarget: text("active_switch_target"), activeSwitchTarget: text("active_switch_target"),
activeCwd: text("active_cwd"), activeCwd: text("active_cwd"),
statusMessage: text("status_message"),
gitStateJson: text("git_state_json"), gitStateJson: text("git_state_json"),
gitStateUpdatedAt: integer("git_state_updated_at"), gitStateUpdatedAt: integer("git_state_updated_at"),
provisionStage: text("provision_stage"),
provisionStageUpdatedAt: integer("provision_stage_updated_at"),
updatedAt: integer("updated_at").notNull(), updatedAt: integer("updated_at").notNull(),
}, },
(table) => [check("task_runtime_singleton_id_check", sql`${table.id} = 1`)], (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 * The task actor is the coordinator for sessions. Each row holds session
* metadata, model, status, transcript, and draft state. Sessions are * metadata, model, status, transcript, and draft state. Sessions are
* sub-entities of the task no separate session actor in the DB. * 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(), sessionId: text("session_id").notNull().primaryKey(),
sandboxSessionId: text("sandbox_session_id"), sandboxSessionId: text("sandbox_session_id"),
sessionName: text("session_name").notNull(), sessionName: text("session_name").notNull(),
@ -68,11 +62,6 @@ export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", {
errorMessage: text("error_message"), errorMessage: text("error_message"),
transcriptJson: text("transcript_json").notNull().default("[]"), transcriptJson: text("transcript_json").notNull().default("[]"),
transcriptUpdatedAt: integer("transcript_updated_at"), 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), created: integer("created").notNull().default(1),
closed: integer("closed").notNull().default(0), closed: integer("closed").notNull().default(0),
thinkingSinceMs: integer("thinking_since_ms"), thinkingSinceMs: integer("thinking_since_ms"),

View file

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

View file

@ -2,8 +2,8 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { getTaskSandbox } from "../../handles.js"; import { getTaskSandbox } from "../../handles.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js"; import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { task as taskTable, taskRuntime } from "../db/schema.js"; import { task as taskTable } from "../db/schema.js";
import { TASK_ROW_ID, appendHistory, getCurrentRecord, setTaskState } from "./common.js"; import { TASK_ROW_ID, appendAuditLog, getCurrentRecord, setTaskState } from "./common.js";
import { pushActiveBranchActivity } from "./push.js"; import { pushActiveBranchActivity } from "./push.js";
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> { 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> { export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void> {
const record = await getCurrentRecord(loopCtx); const record = await getCurrentRecord(loopCtx);
let target = record.sandboxes.find((sandbox: any) => sandbox.sandboxId === record.activeSandboxId)?.switchTarget ?? ""; let target = record.sandboxes.find((sandbox: any) => sandbox.sandboxId === record.activeSandboxId)?.switchTarget ?? "";
const sessionId = msg.body?.sessionId ?? null;
if (record.activeSandboxId) { if (record.activeSandboxId) {
try { 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, target,
sessionId: record.activeSessionId, sessionId,
}); });
await msg.complete({ await msg.complete({
target, target,
sessionId: record.activeSessionId, sessionId,
}); });
} }
@ -64,20 +65,17 @@ export async function handlePushActivity(loopCtx: any, msg: any): Promise<void>
await msg.complete({ ok: true }); await msg.complete({ ok: true });
} }
export async function handleSimpleCommandActivity(loopCtx: any, msg: any, statusMessage: string, historyKind: string): Promise<void> { export async function handleSimpleCommandActivity(loopCtx: any, msg: any, _statusMessage: string, historyKind: string): Promise<void> {
const db = loopCtx.db; await appendAuditLog(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
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 });
await msg.complete({ ok: true }); await msg.complete({ ok: true });
} }
export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<void> { 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); const record = await getCurrentRecord(loopCtx);
if (record.activeSandboxId) { 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) => { 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", { logActorWarning("task.commands", "failed to release sandbox during archive", {
organizationId: loopCtx.state.organizationId, organizationId: loopCtx.state.organizationId,
@ -90,17 +88,15 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
} }
const db = loopCtx.db; 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(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 appendAuditLog(loopCtx, "task.archive", { reason: msg.body?.reason ?? null });
await appendHistory(loopCtx, "task.archive", { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true }); await msg.complete({ ok: true });
} }
export async function killDestroySandboxActivity(loopCtx: any): Promise<void> { 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); const record = await getCurrentRecord(loopCtx);
if (!record.activeSandboxId) { if (!record.activeSandboxId) {
return; return;
@ -110,13 +106,11 @@ export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
} }
export async function killWriteDbActivity(loopCtx: any, msg: 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; 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(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 appendAuditLog(loopCtx, "task.kill", { reason: msg.body?.reason ?? null });
await appendHistory(loopCtx, "task.kill", { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true }); await msg.complete({ ok: true });
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,12 +3,12 @@ import { randomUUID } from "node:crypto";
import { basename, dirname } from "node:path"; import { basename, dirname } from "node:path";
import { asc, eq } from "drizzle-orm"; import { asc, eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../context.js"; 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 { SANDBOX_REPO_CWD } from "../sandbox/index.js";
import { resolveSandboxProviderId } from "../../sandbox-config.js"; import { resolveSandboxProviderId } from "../../sandbox-config.js";
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js"; import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
import { githubRepoFullNameFromRemote } from "../../services/repo.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"; import { getCurrentRecord } from "./workflow/common.js";
function emptyGitState() { 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) { function defaultModelForAgent(agentType: string | null | undefined) {
return agentType === "codex" ? "gpt-5.3-codex" : "claude-sonnet-4"; 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>> { async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise<Array<any>> {
await ensureWorkbenchSessionTable(c); const rows = await c.db.select().from(taskWorkspaceSessions).orderBy(asc(taskWorkspaceSessions.createdAt)).all();
const rows = await c.db.select().from(taskWorkbenchSessions).orderBy(asc(taskWorkbenchSessions.createdAt)).all();
const mapped = rows.map((row: any) => ({ const mapped = rows.map((row: any) => ({
...row, ...row,
id: row.sessionId, id: row.sessionId,
@ -199,8 +162,7 @@ async function nextSessionName(c: any): Promise<string> {
} }
async function readSessionMeta(c: any, sessionId: string): Promise<any | null> { async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
await ensureWorkbenchSessionTable(c); const row = await c.db.select().from(taskWorkspaceSessions).where(eq(taskWorkspaceSessions.sessionId, sessionId)).get();
const row = await c.db.select().from(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.sessionId, sessionId)).get();
if (!row) { if (!row) {
return null; return null;
@ -236,7 +198,6 @@ async function ensureSessionMeta(
errorMessage?: string | null; errorMessage?: string | null;
}, },
): Promise<any> { ): Promise<any> {
await ensureWorkbenchSessionTable(c);
const existing = await readSessionMeta(c, params.sessionId); const existing = await readSessionMeta(c, params.sessionId);
if (existing) { if (existing) {
return existing; return existing;
@ -248,7 +209,7 @@ async function ensureSessionMeta(
const unread = params.unread ?? false; const unread = params.unread ?? false;
await c.db await c.db
.insert(taskWorkbenchSessions) .insert(taskWorkspaceSessions)
.values({ .values({
sessionId: params.sessionId, sessionId: params.sessionId,
sandboxSessionId: params.sandboxSessionId ?? null, sandboxSessionId: params.sandboxSessionId ?? null,
@ -276,19 +237,18 @@ async function ensureSessionMeta(
async function updateSessionMeta(c: any, sessionId: string, values: Record<string, unknown>): Promise<any> { async function updateSessionMeta(c: any, sessionId: string, values: Record<string, unknown>): Promise<any> {
await ensureSessionMeta(c, { sessionId }); await ensureSessionMeta(c, { sessionId });
await c.db await c.db
.update(taskWorkbenchSessions) .update(taskWorkspaceSessions)
.set({ .set({
...values, ...values,
updatedAt: Date.now(), updatedAt: Date.now(),
}) })
.where(eq(taskWorkbenchSessions.sessionId, sessionId)) .where(eq(taskWorkspaceSessions.sessionId, sessionId))
.run(); .run();
return await readSessionMeta(c, sessionId); return await readSessionMeta(c, sessionId);
} }
async function readSessionMetaBySandboxSessionId(c: any, sandboxSessionId: string): Promise<any | null> { async function readSessionMetaBySandboxSessionId(c: any, sandboxSessionId: string): Promise<any | null> {
await ensureWorkbenchSessionTable(c); const row = await c.db.select().from(taskWorkspaceSessions).where(eq(taskWorkspaceSessions.sandboxSessionId, sandboxSessionId)).get();
const row = await c.db.select().from(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.sandboxSessionId, sandboxSessionId)).get();
if (!row) { if (!row) {
return null; return null;
} }
@ -298,17 +258,17 @@ async function readSessionMetaBySandboxSessionId(c: any, sandboxSessionId: strin
async function requireReadySessionMeta(c: any, sessionId: string): Promise<any> { async function requireReadySessionMeta(c: any, sessionId: string): Promise<any> {
const meta = await readSessionMeta(c, sessionId); const meta = await readSessionMeta(c, sessionId);
if (!meta) { if (!meta) {
throw new Error(`Unknown workbench session: ${sessionId}`); throw new Error(`Unknown workspace session: ${sessionId}`);
} }
if (meta.status !== "ready" || !meta.sandboxSessionId) { 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; return meta;
} }
export function requireSendableSessionMeta(meta: any, sessionId: string): any { export function requireSendableSessionMeta(meta: any, sessionId: string): any {
if (!meta) { if (!meta) {
throw new Error(`Unknown workbench session: ${sessionId}`); throw new Error(`Unknown workspace session: ${sessionId}`);
} }
if (meta.status !== "ready" || !meta.sandboxSessionId) { if (meta.status !== "ready" || !meta.sandboxSessionId) {
throw new Error(`Session is not ready (status: ${meta.status}). Wait for session provisioning to complete.`); 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) * 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` * 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; let sandboxRepoPrepared = false;
@ -452,7 +412,7 @@ async function executeInSandbox(
label: string; label: string;
}, },
): Promise<{ exitCode: number; result: string }> { ): Promise<{ exitCode: number; result: string }> {
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkspaceSeeded(c);
const runtime = await getTaskSandboxRuntime(c, record); const runtime = await getTaskSandboxRuntime(c, record);
await ensureSandboxRepo(c, runtime.sandbox, record); await ensureSandboxRepo(c, runtime.sandbox, record);
const response = await runtime.sandbox.runProcess({ const response = await runtime.sandbox.runProcess({
@ -555,7 +515,7 @@ function buildFileTree(paths: string[]): Array<any> {
return sortNodes(root.children.values()); return sortNodes(root.children.values());
} }
async function collectWorkbenchGitState(c: any, record: any) { async function collectWorkspaceGitState(c: any, record: any) {
const activeSandboxId = record.activeSandboxId; const activeSandboxId = record.activeSandboxId;
const activeSandbox = activeSandboxId != null ? ((record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === activeSandboxId) ?? null) : null; const activeSandbox = activeSandboxId != null ? ((record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === activeSandboxId) ?? null) : null;
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? 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 }> { 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 const row = await c.db
.select({ .select({
gitStateJson: taskRuntime.gitStateJson, 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> { 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(); const now = Date.now();
await c.db await c.db
.update(taskRuntime) .update(taskRuntime)
@ -687,19 +645,19 @@ async function writeSessionTranscript(c: any, sessionId: string, transcript: Arr
}); });
} }
async function enqueueWorkbenchRefresh( async function enqueueWorkspaceRefresh(
c: any, 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>, body: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const self = selfTask(c); const self = selfTask(c);
await self.send(command, body, { wait: false }); 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); const self = selfTask(c);
await self.send( await self.send(
"task.command.workbench.ensure_session", "task.command.workspace.ensure_session",
{ {
sessionId, 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"; 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); const gitState = await readCachedGitState(c);
if (record.activeSandboxId && !gitState.updatedAt) { 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) { for (const session of sessions) {
if (session.closed || session.status !== "ready" || !session.sandboxSessionId || session.transcriptUpdatedAt) { if (session.closed || session.status !== "ready" || !session.sandboxSessionId || session.transcriptUpdatedAt) {
continue; continue;
} }
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", {
sessionId: session.sandboxSessionId, sessionId: session.sandboxSessionId,
}); });
} }
@ -756,8 +714,7 @@ async function readPullRequestSummary(c: any, branchName: string | null) {
} }
} }
export async function ensureWorkbenchSeeded(c: any): Promise<any> { export async function ensureWorkspaceSeeded(c: any): Promise<any> {
await ensureTaskRuntimeCacheColumns(c);
const record = await getCurrentRecord({ db: c.db, state: c.state }); const record = await getCurrentRecord({ db: c.db, state: c.state });
if (record.activeSessionId) { if (record.activeSessionId) {
await ensureSessionMeta(c, { 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. * this to the parent organization actor so organization sidebar reads stay local.
*/ */
export async function buildTaskSummary(c: any): Promise<any> { export async function buildTaskSummary(c: any): Promise<any> {
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkspaceSeeded(c);
const sessions = await listSessionMetaRows(c); const sessions = await listSessionMetaRows(c);
await maybeScheduleWorkbenchRefreshes(c, record, sessions); await maybeScheduleWorkspaceRefreshes(c, record, sessions);
return { return {
id: c.state.taskId, 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. * subscribers. This is a full replacement payload, not a patch.
*/ */
export async function buildTaskDetail(c: any): Promise<any> { export async function buildTaskDetail(c: any): Promise<any> {
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkspaceSeeded(c);
const gitState = await readCachedGitState(c); const gitState = await readCachedGitState(c);
const sessions = await listSessionMetaRows(c); const sessions = await listSessionMetaRows(c);
await maybeScheduleWorkbenchRefreshes(c, record, sessions); await maybeScheduleWorkspaceRefreshes(c, record, sessions);
const summary = await buildTaskSummary(c); const summary = await buildTaskSummary(c);
return { 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> { 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); const meta = await readSessionMeta(c, sessionId);
if (!meta || meta.closed) { if (!meta || meta.closed) {
throw new Error(`Unknown workbench session: ${sessionId}`); throw new Error(`Unknown workspace session: ${sessionId}`);
} }
if (!meta.sandboxSessionId) { 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: * The task actor emits two kinds of updates:
* - Push summary state up to the parent organization actor so the sidebar * - 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. * - Broadcast full detail/session payloads down to direct task subscribers.
*/ */
export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }): Promise<void> { export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }): Promise<void> {
const organization = await getOrCreateOrganization(c, c.state.organizationId); const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote);
await organization.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) }); await repository.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) });
c.broadcast("taskUpdated", { c.broadcast("taskUpdated", {
type: "taskDetailUpdated", type: "taskUpdated",
detail: await buildTaskDetail(c), 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> { export async function refreshWorkspaceDerivedState(c: any): Promise<void> {
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkspaceSeeded(c);
const gitState = await collectWorkbenchGitState(c, record); const gitState = await collectWorkspaceGitState(c, record);
await writeCachedGitState(c, gitState); await writeCachedGitState(c, gitState);
await broadcastTaskUpdate(c); await broadcastTaskUpdate(c);
} }
export async function refreshWorkbenchSessionTranscript(c: any, sessionId: string): Promise<void> { export async function refreshWorkspaceSessionTranscript(c: any, sessionId: string): Promise<void> {
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkspaceSeeded(c);
const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await readSessionMeta(c, sessionId)); const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await readSessionMeta(c, sessionId));
if (!meta?.sandboxSessionId) { if (!meta?.sandboxSessionId) {
return; return;
@ -967,7 +924,7 @@ export async function refreshWorkbenchSessionTranscript(c: any, sessionId: strin
await broadcastTaskUpdate(c, { sessionId: meta.sessionId }); 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(); const nextTitle = value.trim();
if (!nextTitle) { if (!nextTitle) {
throw new Error("task title is required"); throw new Error("task title is required");
@ -985,81 +942,30 @@ export async function renameWorkbenchTask(c: any, value: string): Promise<void>
await broadcastTaskUpdate(c); await broadcastTaskUpdate(c);
} }
export async function renameWorkbenchBranch(c: any, value: string): Promise<void> { export async function createWorkspaceSession(c: any, model?: string): Promise<{ sessionId: string }> {
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 }> {
const sessionId = `session-${randomUUID()}`; const sessionId = `session-${randomUUID()}`;
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkspaceSeeded(c);
await ensureSessionMeta(c, { await ensureSessionMeta(c, {
sessionId, sessionId,
model: model ?? defaultModelForAgent(record.agentType), model: model ?? defaultModelForAgent(record.agentType),
sandboxSessionId: null, sandboxSessionId: null,
status: pendingWorkbenchSessionStatus(record), status: pendingWorkspaceSessionStatus(record),
created: false, created: false,
}); });
await broadcastTaskUpdate(c, { sessionId: sessionId }); await broadcastTaskUpdate(c, { sessionId: sessionId });
await enqueueWorkbenchEnsureSession(c, sessionId); await enqueueWorkspaceEnsureSession(c, sessionId);
return { 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); const meta = await readSessionMeta(c, sessionId);
if (!meta || meta.closed) { if (!meta || meta.closed) {
return; return;
} }
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkspaceSeeded(c);
if (meta.sandboxSessionId && meta.status === "ready") { 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, sessionId: meta.sandboxSessionId,
}); });
await broadcastTaskUpdate(c, { sessionId: sessionId }); await broadcastTaskUpdate(c, { sessionId: sessionId });
@ -1089,7 +995,7 @@ export async function ensureWorkbenchSession(c: any, sessionId: string, model?:
status: "ready", status: "ready",
errorMessage: null, errorMessage: null,
}); });
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", {
sessionId: meta.sandboxSessionId ?? sessionId, sessionId: meta.sandboxSessionId ?? sessionId,
}); });
} catch (error) { } catch (error) {
@ -1102,7 +1008,7 @@ export async function ensureWorkbenchSession(c: any, sessionId: string, model?:
await broadcastTaskUpdate(c, { sessionId: sessionId }); 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 self = selfTask(c);
const pending = (await listSessionMetaRows(c, { includeClosed: true })).filter( const pending = (await listSessionMetaRows(c, { includeClosed: true })).filter(
(row) => row.closed !== true && row.status !== "ready" && row.status !== "error", (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) { for (const row of pending) {
await self.send( await self.send(
"task.command.workbench.ensure_session", "task.command.workspace.ensure_session",
{ {
sessionId: row.sessionId, sessionId: row.sessionId,
model: row.model, 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(); const trimmed = title.trim();
if (!trimmed) { if (!trimmed) {
throw new Error("session title is required"); 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 }); 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, { await updateSessionMeta(c, sessionId, {
unread: unread ? 1 : 0, unread: unread ? 1 : 0,
}); });
await broadcastTaskUpdate(c, { sessionId }); 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, { await updateSessionMeta(c, sessionId, {
draftText: text, draftText: text,
draftAttachmentsJson: JSON.stringify(attachments), draftAttachmentsJson: JSON.stringify(attachments),
@ -1149,7 +1055,7 @@ export async function updateWorkbenchDraft(c: any, sessionId: string, text: stri
await broadcastTaskUpdate(c, { sessionId }); 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); const meta = await readSessionMeta(c, sessionId);
if (!meta || meta.closed) { if (!meta || meta.closed) {
return; return;
@ -1159,7 +1065,7 @@ export async function changeWorkbenchModel(c: any, sessionId: string, model: str
return; return;
} }
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkspaceSeeded(c);
let nextMeta = await updateSessionMeta(c, sessionId, { let nextMeta = await updateSessionMeta(c, sessionId, {
model, model,
}); });
@ -1170,7 +1076,7 @@ export async function changeWorkbenchModel(c: any, sessionId: string, model: str
await sandbox.destroySession(nextMeta.sandboxSessionId); await sandbox.destroySession(nextMeta.sandboxSessionId);
nextMeta = await updateSessionMeta(c, sessionId, { nextMeta = await updateSessionMeta(c, sessionId, {
sandboxSessionId: null, sandboxSessionId: null,
status: pendingWorkbenchSessionStatus(record), status: pendingWorkspaceSessionStatus(record),
errorMessage: null, errorMessage: null,
transcriptJson: "[]", transcriptJson: "[]",
transcriptUpdatedAt: null, transcriptUpdatedAt: null,
@ -1191,20 +1097,20 @@ export async function changeWorkbenchModel(c: any, sessionId: string, model: str
} }
} else if (nextMeta.status !== "ready") { } else if (nextMeta.status !== "ready") {
nextMeta = await updateSessionMeta(c, sessionId, { nextMeta = await updateSessionMeta(c, sessionId, {
status: pendingWorkbenchSessionStatus(record), status: pendingWorkspaceSessionStatus(record),
errorMessage: null, errorMessage: null,
}); });
} }
if (shouldEnsure) { if (shouldEnsure) {
await enqueueWorkbenchEnsureSession(c, sessionId); await enqueueWorkspaceEnsureSession(c, sessionId);
} }
await broadcastTaskUpdate(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 meta = requireSendableSessionMeta(await readSessionMeta(c, sessionId), sessionId);
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkspaceSeeded(c);
const runtime = await getTaskSandboxRuntime(c, record); const runtime = await getTaskSandboxRuntime(c, record);
// Skip git fetch on subsequent messages — the repo was already prepared during session // 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. // 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)) .where(eq(taskRuntime.id, 1))
.run(); .run();
await syncWorkbenchSessionStatus(c, meta.sandboxSessionId, "running", Date.now()); await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "running", Date.now());
try { try {
await runtime.sandbox.sendPrompt({ await runtime.sandbox.sendPrompt({
sessionId: meta.sandboxSessionId, sessionId: meta.sandboxSessionId,
prompt: prompt.join("\n\n"), prompt: prompt.join("\n\n"),
}); });
await syncWorkbenchSessionStatus(c, meta.sandboxSessionId, "idle", Date.now()); await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "idle", Date.now());
} catch (error) { } catch (error) {
await updateSessionMeta(c, sessionId, { await updateSessionMeta(c, sessionId, {
status: "error", status: "error",
errorMessage: error instanceof Error ? error.message : String(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; 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 meta = await requireReadySessionMeta(c, sessionId);
const sandbox = getTaskSandbox(c, c.state.organizationId, stableSandboxId(c)); const sandbox = getTaskSandbox(c, c.state.organizationId, stableSandboxId(c));
await sandbox.destroySession(meta.sandboxSessionId); await sandbox.destroySession(meta.sandboxSessionId);
@ -1262,8 +1168,8 @@ export async function stopWorkbenchSession(c: any, sessionId: string): Promise<v
await broadcastTaskUpdate(c, { sessionId }); await broadcastTaskUpdate(c, { sessionId });
} }
export async function syncWorkbenchSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise<void> { export async function syncWorkspaceSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise<void> {
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkspaceSeeded(c);
const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await ensureSessionMeta(c, { sessionId: sessionId, sandboxSessionId: sessionId })); const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await ensureSessionMeta(c, { sessionId: sessionId, sandboxSessionId: sessionId }));
let changed = false; let changed = false;
@ -1318,18 +1224,18 @@ export async function syncWorkbenchSessionStatus(c: any, sessionId: string, stat
} }
if (changed) { if (changed) {
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", {
sessionId, sessionId,
}); });
if (status !== "running") { 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 }); await broadcastTaskUpdate(c, { sessionId: meta.sessionId });
} }
} }
export async function closeWorkbenchSession(c: any, sessionId: string): Promise<void> { export async function closeWorkspaceSession(c: any, sessionId: string): Promise<void> {
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkspaceSeeded(c);
const sessions = await listSessionMetaRows(c); const sessions = await listSessionMetaRows(c);
if (sessions.filter((candidate) => candidate.closed !== true).length <= 1) { if (sessions.filter((candidate) => candidate.closed !== true).length <= 1) {
return; return;
@ -1360,7 +1266,7 @@ export async function closeWorkbenchSession(c: any, sessionId: string): Promise<
await broadcastTaskUpdate(c); 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 sessions = await listSessionMetaRows(c);
const latest = sessions[sessions.length - 1]; const latest = sessions[sessions.length - 1];
if (!latest) { if (!latest) {
@ -1372,8 +1278,8 @@ export async function markWorkbenchUnread(c: any): Promise<void> {
await broadcastTaskUpdate(c, { sessionId: latest.sessionId }); await broadcastTaskUpdate(c, { sessionId: latest.sessionId });
} }
export async function publishWorkbenchPr(c: any): Promise<void> { export async function publishWorkspacePr(c: any): Promise<void> {
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkspaceSeeded(c);
if (!record.branchName) { if (!record.branchName) {
throw new Error("cannot publish PR without a branch"); throw new Error("cannot publish PR without a branch");
} }
@ -1400,8 +1306,8 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
await broadcastTaskUpdate(c); await broadcastTaskUpdate(c);
} }
export async function revertWorkbenchFile(c: any, path: string): Promise<void> { export async function revertWorkspaceFile(c: any, path: string): Promise<void> {
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkspaceSeeded(c);
if (!record.activeSandboxId) { if (!record.activeSandboxId) {
throw new Error("cannot revert file without an active sandbox"); 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) { if (result.exitCode !== 0) {
throw new Error(`file revert failed (${result.exitCode}): ${result.result}`); 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); await broadcastTaskUpdate(c);
} }

View file

@ -2,4 +2,4 @@ import { db } from "rivetkit/db/drizzle";
import * as schema from "./schema.js"; import * as schema from "./schema.js";
import migrations from "./migrations.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", tag: "0000_auth_user",
breakpoints: true, breakpoints: true,
}, },
{
idx: 1,
when: 1773532800000,
tag: "0001_user_task_state",
breakpoints: true,
},
], ],
} as const; } as const;
@ -58,23 +64,39 @@ CREATE TABLE \`account\` (
CREATE UNIQUE INDEX \`account_provider_account_idx\` ON \`account\` (\`provider_id\`, \`account_id\`); CREATE UNIQUE INDEX \`account_provider_account_idx\` ON \`account\` (\`provider_id\`, \`account_id\`);
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE \`user_profiles\` ( 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_account_id\` text,
\`github_login\` text, \`github_login\` text,
\`role_label\` text NOT NULL, \`role_label\` text NOT NULL,
\`default_model\` text DEFAULT 'claude-sonnet-4' NOT NULL,
\`eligible_organization_ids_json\` text NOT NULL, \`eligible_organization_ids_json\` text NOT NULL,
\`starter_repo_status\` text NOT NULL, \`starter_repo_status\` text NOT NULL,
\`starter_repo_starred_at\` integer, \`starter_repo_starred_at\` integer,
\`starter_repo_skipped_at\` integer, \`starter_repo_skipped_at\` integer,
\`created_at\` integer NOT NULL, \`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 --> statement-breakpoint
CREATE UNIQUE INDEX \`user_profiles_user_id_idx\` ON \`user_profiles\` (\`user_id\`);
--> statement-breakpoint
CREATE TABLE \`session_state\` ( CREATE TABLE \`session_state\` (
\`session_id\` text PRIMARY KEY NOT NULL, \`session_id\` text PRIMARY KEY NOT NULL,
\`active_organization_id\` text, \`active_organization_id\` text,
\`created_at\` integer NOT NULL, \`created_at\` integer NOT NULL,
\`updated_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, } 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 { and, asc, count as sqlCount, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, notInArray, or } from "drizzle-orm";
import { actor } from "rivetkit"; import { actor } from "rivetkit";
import { authUserDb } from "./db/db.js"; import { userDb } from "./db/db.js";
import { authAccounts, authSessions, authUsers, sessionState, userProfiles } from "./db/schema.js"; import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js";
const tables = { const tables = {
user: authUsers, user: authUsers,
@ -9,12 +9,13 @@ const tables = {
account: authAccounts, account: authAccounts,
userProfiles, userProfiles,
sessionState, sessionState,
userTaskState,
} as const; } as const;
function tableFor(model: string) { function tableFor(model: string) {
const table = tables[model as keyof typeof tables]; const table = tables[model as keyof typeof tables];
if (!table) { if (!table) {
throw new Error(`Unsupported auth user model: ${model}`); throw new Error(`Unsupported user model: ${model}`);
} }
return table as any; return table as any;
} }
@ -22,7 +23,7 @@ function tableFor(model: string) {
function columnFor(table: any, field: string) { function columnFor(table: any, field: string) {
const column = table[field]; const column = table[field];
if (!column) { if (!column) {
throw new Error(`Unsupported auth user field: ${field}`); throw new Error(`Unsupported user field: ${field}`);
} }
return column; return column;
} }
@ -150,10 +151,10 @@ async function applyJoinToRows(c: any, model: string, rows: any[], join: any) {
return rows; return rows;
} }
export const authUser = actor({ export const user = actor({
db: authUserDb, db: userDb,
options: { options: {
name: "Auth User", name: "User",
icon: "shield", icon: "shield",
actionTimeout: 60_000, actionTimeout: 60_000,
}, },
@ -161,6 +162,8 @@ export const authUser = actor({
userId: input.userId, userId: input.userId,
}), }),
actions: { 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> }) { async createAuthRecord(c, input: { model: string; data: Record<string, unknown> }) {
const table = tableFor(input.model); const table = tableFor(input.model);
await c.db await c.db
@ -174,6 +177,8 @@ export const authUser = actor({
.get(); .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 }) { async findOneAuthRecord(c, input: { model: string; where: any[]; join?: any }) {
const table = tableFor(input.model); const table = tableFor(input.model);
const predicate = buildWhere(table, input.where); const predicate = buildWhere(table, input.where);
@ -181,6 +186,8 @@ export const authUser = actor({
return await applyJoinToRow(c, input.model, row ?? null, input.join); 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 }) { async findManyAuthRecords(c, input: { model: string; where?: any[]; limit?: number; offset?: number; sortBy?: any; join?: any }) {
const table = tableFor(input.model); const table = tableFor(input.model);
const predicate = buildWhere(table, input.where); const predicate = buildWhere(table, input.where);
@ -202,6 +209,8 @@ export const authUser = actor({
return await applyJoinToRows(c, input.model, rows, input.join); 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> }) { async updateAuthRecord(c, input: { model: string; where: any[]; update: Record<string, unknown> }) {
const table = tableFor(input.model); const table = tableFor(input.model);
const predicate = buildWhere(table, input.where); const predicate = buildWhere(table, input.where);
@ -216,6 +225,8 @@ export const authUser = actor({
return await c.db.select().from(table).where(predicate).get(); 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> }) { async updateManyAuthRecords(c, input: { model: string; where: any[]; update: Record<string, unknown> }) {
const table = tableFor(input.model); const table = tableFor(input.model);
const predicate = buildWhere(table, input.where); const predicate = buildWhere(table, input.where);
@ -231,6 +242,8 @@ export const authUser = actor({
return row?.value ?? 0; 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[] }) { async deleteAuthRecord(c, input: { model: string; where: any[] }) {
const table = tableFor(input.model); const table = tableFor(input.model);
const predicate = buildWhere(table, input.where); const predicate = buildWhere(table, input.where);
@ -240,6 +253,8 @@ export const authUser = actor({
await c.db.delete(table).where(predicate).run(); 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[] }) { async deleteManyAuthRecords(c, input: { model: string; where: any[] }) {
const table = tableFor(input.model); const table = tableFor(input.model);
const predicate = buildWhere(table, input.where); const predicate = buildWhere(table, input.where);
@ -251,6 +266,8 @@ export const authUser = actor({
return rows.length; 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[] }) { async countAuthRecords(c, input: { model: string; where?: any[] }) {
const table = tableFor(input.model); const table = tableFor(input.model);
const predicate = buildWhere(table, input.where); const predicate = buildWhere(table, input.where);
@ -260,6 +277,7 @@ export const authUser = actor({
return row?.value ?? 0; return row?.value ?? 0;
}, },
// Custom Foundry action — not part of Better Auth.
async getAppAuthState(c, input: { sessionId: string }) { async getAppAuthState(c, input: { sessionId: string }) {
const session = await c.db.select().from(authSessions).where(eq(authSessions.id, input.sessionId)).get(); const session = await c.db.select().from(authSessions).where(eq(authSessions.id, input.sessionId)).get();
if (!session) { if (!session) {
@ -280,6 +298,7 @@ export const authUser = actor({
}; };
}, },
// Custom Foundry action — not part of Better Auth.
async upsertUserProfile( async upsertUserProfile(
c, c,
input: { input: {
@ -288,6 +307,7 @@ export const authUser = actor({
githubAccountId?: string | null; githubAccountId?: string | null;
githubLogin?: string | null; githubLogin?: string | null;
roleLabel?: string; roleLabel?: string;
defaultModel?: string;
eligibleOrganizationIdsJson?: string; eligibleOrganizationIdsJson?: string;
starterRepoStatus?: string; starterRepoStatus?: string;
starterRepoStarredAt?: number | null; starterRepoStarredAt?: number | null;
@ -299,10 +319,12 @@ export const authUser = actor({
await c.db await c.db
.insert(userProfiles) .insert(userProfiles)
.values({ .values({
id: 1,
userId: input.userId, userId: input.userId,
githubAccountId: input.patch.githubAccountId ?? null, githubAccountId: input.patch.githubAccountId ?? null,
githubLogin: input.patch.githubLogin ?? null, githubLogin: input.patch.githubLogin ?? null,
roleLabel: input.patch.roleLabel ?? "GitHub user", roleLabel: input.patch.roleLabel ?? "GitHub user",
defaultModel: input.patch.defaultModel ?? "claude-sonnet-4",
eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]", eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]",
starterRepoStatus: input.patch.starterRepoStatus ?? "pending", starterRepoStatus: input.patch.starterRepoStatus ?? "pending",
starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null, starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null,
@ -316,6 +338,7 @@ export const authUser = actor({
...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}), ...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}),
...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}), ...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}),
...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}), ...(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.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}),
...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}), ...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}),
...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}), ...(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(); 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 }) { async upsertSessionState(c, input: { sessionId: string; activeOrganizationId: string | null }) {
const now = Date.now(); const now = Date.now();
await c.db 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(); 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 { betterAuth } from "better-auth";
import { createAdapterFactory } from "better-auth/adapters"; import { createAdapterFactory } from "better-auth/adapters";
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/app-shell.js"; import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/app-shell.js";
import { authUserKey, organizationKey } from "../actors/keys.js"; import { organizationKey, userKey } from "../actors/keys.js";
import { logger } from "../logging.js"; import { logger } from "../logging.js";
const AUTH_BASE_PATH = "/v1/auth"; 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 // 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. // actors must exist by the time the adapter needs them.
const appOrganization = () => const appOrganization = () =>
actorClient.organization.getOrCreate(organizationKey(APP_SHELL_ORGANIZATION_ID), { 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 // getOrCreate is intentional: Better Auth creates user records during OAuth
// callbacks, so the auth-user actor must be lazily provisioned on first access. // callbacks, so the user actor must be lazily provisioned on first access.
const getAuthUser = async (userId: string) => const getUser = async (userId: string) =>
await actorClient.authUser.getOrCreate(authUserKey(userId), { await actorClient.user.getOrCreate(userKey(userId), {
createWithInput: { 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})`); 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 created = await userActor.createAuthRecord({ model, data: transformed });
const organization = await appOrganization(); const organization = await appOrganization();
@ -220,7 +220,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return null; return null;
} }
const userActor = await getAuthUser(userId); const userActor = await getUser(userId);
const found = await userActor.findOneAuthRecord({ model, where: transformedWhere, join }); const found = await userActor.findOneAuthRecord({ model, where: transformedWhere, join });
return found ? ((await transformOutput(found, model, undefined, join)) as any) : null; 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 = []; const rows = [];
for (const [userId, tokens] of byUser) { for (const [userId, tokens] of byUser) {
const userActor = await getAuthUser(userId); const userActor = await getUser(userId);
const scopedWhere = transformedWhere.map((entry: any) => const scopedWhere = transformedWhere.map((entry: any) =>
entry.field === "token" && entry.operator === "in" ? { ...entry, value: tokens } : entry, entry.field === "token" && entry.operator === "in" ? { ...entry, value: tokens } : entry,
); );
@ -275,7 +275,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return []; return [];
} }
const userActor = await getAuthUser(userId); const userActor = await getUser(userId);
const found = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit, sortBy, offset, join }); 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))); 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; return null;
} }
const userActor = await getAuthUser(userId); const userActor = await getUser(userId);
const before = const before =
model === "user" model === "user"
? await userActor.findOneAuthRecord({ model, where: transformedWhere }) ? await userActor.findOneAuthRecord({ model, where: transformedWhere })
@ -345,7 +345,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return 0; return 0;
} }
const userActor = await getAuthUser(userId); const userActor = await getUser(userId);
return await userActor.updateManyAuthRecords({ model, where: transformedWhere, update: transformedUpdate }); return await userActor.updateManyAuthRecords({ model, where: transformedWhere, update: transformedUpdate });
}, },
@ -361,7 +361,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return; return;
} }
const userActor = await getAuthUser(userId); const userActor = await getUser(userId);
const organization = await appOrganization(); const organization = await appOrganization();
const before = await userActor.findOneAuthRecord({ model, where: transformedWhere }); const before = await userActor.findOneAuthRecord({ model, where: transformedWhere });
await userActor.deleteAuthRecord({ model, where: transformedWhere }); await userActor.deleteAuthRecord({ model, where: transformedWhere });
@ -397,7 +397,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
if (!userId) { if (!userId) {
return 0; return 0;
} }
const userActor = await getAuthUser(userId); const userActor = await getUser(userId);
const organization = await appOrganization(); const organization = await appOrganization();
const sessions = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit: 5000 }); const sessions = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit: 5000 });
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere }); const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
@ -415,7 +415,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return 0; return 0;
} }
const userActor = await getAuthUser(userId); const userActor = await getUser(userId);
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere }); const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
return deleted; return deleted;
}, },
@ -431,7 +431,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return 0; return 0;
} }
const userActor = await getAuthUser(userId); const userActor = await getUser(userId);
return await userActor.countAuthRecords({ model, where: transformedWhere }); return await userActor.countAuthRecords({ model, where: transformedWhere });
}, },
}; };
@ -481,12 +481,12 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
if (!route?.userId) { if (!route?.userId) {
return null; return null;
} }
const userActor = await getAuthUser(route.userId); const userActor = await getUser(route.userId);
return await userActor.getAppAuthState({ sessionId }); return await userActor.getAppAuthState({ sessionId });
}, },
async upsertUserProfile(userId: string, patch: Record<string, unknown>) { async upsertUserProfile(userId: string, patch: Record<string, unknown>) {
const userActor = await getAuthUser(userId); const userActor = await getUser(userId);
return await userActor.upsertUserProfile({ userId, patch }); return await userActor.upsertUserProfile({ userId, patch });
}, },
@ -495,7 +495,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
if (!authState?.user?.id) { if (!authState?.user?.id) {
throw new Error(`Unknown auth session ${sessionId}`); 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 }); return await userActor.upsertSessionState({ sessionId, activeOrganizationId });
}, },

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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", () => { describe("actor keys", () => {
it("prefixes every key with organization namespace", () => { it("prefixes every key with organization namespace", () => {
@ -8,7 +8,7 @@ describe("actor keys", () => {
repositoryKey("default", "repo"), repositoryKey("default", "repo"),
taskKey("default", "repo", "task"), taskKey("default", "repo", "task"),
taskSandboxKey("default", "sbx"), taskSandboxKey("default", "sbx"),
historyKey("default", "repo"), auditLogKey("default", "repo"),
githubDataKey("default"), githubDataKey("default"),
]; ];

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; 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", () => { it("marks unread when a running session first becomes idle", () => {
expect(shouldMarkSessionUnreadForStatus({ thinkingSinceMs: Date.now() - 1_000 }, "idle")).toBe(true); 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", () => { it("recreates an unused ready session so the selected model takes effect", () => {
expect( expect(
shouldRecreateSessionForModelChange({ shouldRecreateSessionForModelChange({
@ -58,9 +58,9 @@ describe("workbench model changes", () => {
}); });
}); });
describe("workbench send readiness", () => { describe("workspace send readiness", () => {
it("rejects unknown sessions", () => { 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", () => { it("rejects pending sessions", () => {

View file

@ -10,8 +10,8 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:e2e:full": "HF_ENABLE_DAEMON_FULL_E2E=1 vitest run test/e2e/full-integration-e2e.test.ts", "test:e2e:full": "HF_ENABLE_DAEMON_FULL_E2E=1 vitest run test/e2e/full-integration-e2e.test.ts",
"test:e2e:workbench": "HF_ENABLE_DAEMON_WORKBENCH_E2E=1 vitest run test/e2e/workbench-e2e.test.ts", "test:e2e:workspace": "HF_ENABLE_DAEMON_WORKBENCH_E2E=1 vitest run test/e2e/workspace-e2e.test.ts",
"test:e2e:workbench-load": "HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E=1 vitest run test/e2e/workbench-load-e2e.test.ts" "test:e2e:workspace-load": "HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E=1 vitest run test/e2e/workspace-load-e2e.test.ts"
}, },
"dependencies": { "dependencies": {
"@sandbox-agent/foundry-shared": "workspace:*", "@sandbox-agent/foundry-shared": "workspace:*",

View file

@ -4,6 +4,7 @@ import type {
FoundryOrganization, FoundryOrganization,
FoundryUser, FoundryUser,
UpdateFoundryOrganizationProfileInput, UpdateFoundryOrganizationProfileInput,
WorkspaceModelId,
} from "@sandbox-agent/foundry-shared"; } from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "./backend-client.js"; import type { BackendClient } from "./backend-client.js";
import { getMockFoundryAppClient } from "./mock-app.js"; import { getMockFoundryAppClient } from "./mock-app.js";
@ -17,6 +18,7 @@ export interface FoundryAppClient {
skipStarterRepo(): Promise<void>; skipStarterRepo(): Promise<void>;
starStarterRepo(organizationId: string): Promise<void>; starStarterRepo(organizationId: string): Promise<void>;
selectOrganization(organizationId: string): Promise<void>; selectOrganization(organizationId: string): Promise<void>;
setDefaultModel(model: WorkspaceModelId): Promise<void>;
updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void>; updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void>;
triggerGithubSync(organizationId: string): Promise<void>; triggerGithubSync(organizationId: string): Promise<void>;
completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void>; completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void>;

View file

@ -10,25 +10,25 @@ import type {
SandboxProcessesEvent, SandboxProcessesEvent,
TaskRecord, TaskRecord,
TaskSummary, TaskSummary,
TaskWorkbenchChangeModelInput, TaskWorkspaceChangeModelInput,
TaskWorkbenchCreateTaskInput, TaskWorkspaceCreateTaskInput,
TaskWorkbenchCreateTaskResponse, TaskWorkspaceCreateTaskResponse,
TaskWorkbenchDiffInput, TaskWorkspaceDiffInput,
TaskWorkbenchRenameInput, TaskWorkspaceRenameInput,
TaskWorkbenchRenameSessionInput, TaskWorkspaceRenameSessionInput,
TaskWorkbenchSelectInput, TaskWorkspaceSelectInput,
TaskWorkbenchSetSessionUnreadInput, TaskWorkspaceSetSessionUnreadInput,
TaskWorkbenchSendMessageInput, TaskWorkspaceSendMessageInput,
TaskWorkbenchSnapshot, TaskWorkspaceSnapshot,
TaskWorkbenchSessionInput, TaskWorkspaceSessionInput,
TaskWorkbenchUpdateDraftInput, TaskWorkspaceUpdateDraftInput,
TaskEvent, TaskEvent,
WorkbenchTaskDetail, WorkspaceTaskDetail,
WorkbenchTaskSummary, WorkspaceTaskSummary,
WorkbenchSessionDetail, WorkspaceSessionDetail,
OrganizationEvent, OrganizationEvent,
OrganizationSummarySnapshot, OrganizationSummarySnapshot,
HistoryEvent, AuditLogEvent as HistoryEvent,
HistoryQueryInput, HistoryQueryInput,
SandboxProviderId, SandboxProviderId,
RepoOverview, RepoOverview,
@ -37,6 +37,7 @@ import type {
StarSandboxAgentRepoResult, StarSandboxAgentRepoResult,
SwitchResult, SwitchResult,
UpdateFoundryOrganizationProfileInput, UpdateFoundryOrganizationProfileInput,
WorkspaceModelId,
} from "@sandbox-agent/foundry-shared"; } from "@sandbox-agent/foundry-shared";
import type { ProcessCreateRequest, ProcessInfo, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent"; import type { ProcessCreateRequest, ProcessInfo, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
import { createMockBackendClient } from "./mock/backend-client.js"; import { createMockBackendClient } from "./mock/backend-client.js";
@ -78,39 +79,36 @@ interface OrganizationHandle {
createTask(input: CreateTaskInput): Promise<TaskRecord>; createTask(input: CreateTaskInput): Promise<TaskRecord>;
listTasks(input: { organizationId: string; repoId?: string }): Promise<TaskSummary[]>; listTasks(input: { organizationId: string; repoId?: string }): Promise<TaskSummary[]>;
getRepoOverview(input: { organizationId: string; repoId: string }): Promise<RepoOverview>; getRepoOverview(input: { organizationId: string; repoId: string }): Promise<RepoOverview>;
history(input: HistoryQueryInput): Promise<HistoryEvent[]>; auditLog(input: HistoryQueryInput): Promise<HistoryEvent[]>;
switchTask(taskId: string): Promise<SwitchResult>; switchTask(input: { repoId: string; taskId: string }): Promise<SwitchResult>;
getTask(input: { organizationId: string; taskId: string }): Promise<TaskRecord>; getTask(input: { organizationId: string; repoId: string; taskId: string }): Promise<TaskRecord>;
attachTask(input: { organizationId: string; taskId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>; attachTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>;
pushTask(input: { organizationId: string; taskId: string; reason?: string }): Promise<void>; pushTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise<void>;
syncTask(input: { organizationId: string; taskId: string; reason?: string }): Promise<void>; syncTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise<void>;
mergeTask(input: { organizationId: string; taskId: string; reason?: string }): Promise<void>; mergeTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise<void>;
archiveTask(input: { organizationId: string; taskId: string; reason?: string }): Promise<void>; archiveTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise<void>;
killTask(input: { organizationId: string; taskId: string; reason?: string }): Promise<void>; killTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise<void>;
useOrganization(input: { organizationId: string }): Promise<{ organizationId: string }>; useOrganization(input: { organizationId: string }): Promise<{ organizationId: string }>;
starSandboxAgentRepo(input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult>; starSandboxAgentRepo(input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult>;
getOrganizationSummary(input: { organizationId: string }): Promise<OrganizationSummarySnapshot>; getOrganizationSummary(input: { organizationId: string }): Promise<OrganizationSummarySnapshot>;
applyTaskSummaryUpdate(input: { taskSummary: WorkbenchTaskSummary }): Promise<void>; adminReconcileWorkspaceState(input: { organizationId: string }): Promise<OrganizationSummarySnapshot>;
removeTaskSummary(input: { taskId: string }): Promise<void>; createWorkspaceTask(input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse>;
reconcileWorkbenchState(input: { organizationId: string }): Promise<OrganizationSummarySnapshot>; markWorkspaceUnread(input: TaskWorkspaceSelectInput): Promise<void>;
createWorkbenchTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>; renameWorkspaceTask(input: TaskWorkspaceRenameInput): Promise<void>;
markWorkbenchUnread(input: TaskWorkbenchSelectInput): Promise<void>; createWorkspaceSession(input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }>;
renameWorkbenchTask(input: TaskWorkbenchRenameInput): Promise<void>; renameWorkspaceSession(input: TaskWorkspaceRenameSessionInput): Promise<void>;
renameWorkbenchBranch(input: TaskWorkbenchRenameInput): Promise<void>; setWorkspaceSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
createWorkbenchSession(input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }>; updateWorkspaceDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void>;
renameWorkbenchSession(input: TaskWorkbenchRenameSessionInput): Promise<void>; changeWorkspaceModel(input: TaskWorkspaceChangeModelInput): Promise<void>;
setWorkbenchSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void>; sendWorkspaceMessage(input: TaskWorkspaceSendMessageInput): Promise<void>;
updateWorkbenchDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void>; stopWorkspaceSession(input: TaskWorkspaceSessionInput): Promise<void>;
changeWorkbenchModel(input: TaskWorkbenchChangeModelInput): Promise<void>; closeWorkspaceSession(input: TaskWorkspaceSessionInput): Promise<void>;
sendWorkbenchMessage(input: TaskWorkbenchSendMessageInput): Promise<void>; publishWorkspacePr(input: TaskWorkspaceSelectInput): Promise<void>;
stopWorkbenchSession(input: TaskWorkbenchSessionInput): Promise<void>; revertWorkspaceFile(input: TaskWorkspaceDiffInput): Promise<void>;
closeWorkbenchSession(input: TaskWorkbenchSessionInput): Promise<void>; adminReloadGithubOrganization(): Promise<void>;
publishWorkbenchPr(input: TaskWorkbenchSelectInput): Promise<void>; adminReloadGithubPullRequests(): Promise<void>;
revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>; adminReloadGithubRepository(input: { repoId: string }): Promise<void>;
reloadGithubOrganization(): Promise<void>; adminReloadGithubPullRequest(input: { repoId: string; prNumber: number }): Promise<void>;
reloadGithubPullRequests(): Promise<void>;
reloadGithubRepository(input: { repoId: string }): Promise<void>;
reloadGithubPullRequest(input: { repoId: string; prNumber: number }): Promise<void>;
} }
interface AppOrganizationHandle { interface AppOrganizationHandle {
@ -119,6 +117,7 @@ interface AppOrganizationHandle {
skipAppStarterRepo(input: { sessionId: string }): Promise<FoundryAppSnapshot>; skipAppStarterRepo(input: { sessionId: string }): Promise<FoundryAppSnapshot>;
starAppStarterRepo(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>; starAppStarterRepo(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
selectAppOrganization(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>; selectAppOrganization(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
setAppDefaultModel(input: { sessionId: string; defaultModel: WorkspaceModelId }): Promise<FoundryAppSnapshot>;
updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput & { sessionId: string }): Promise<FoundryAppSnapshot>; updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput & { sessionId: string }): Promise<FoundryAppSnapshot>;
triggerAppRepoImport(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>; triggerAppRepoImport(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
beginAppGithubInstall(input: { sessionId: string; organizationId: string }): Promise<{ url: string }>; beginAppGithubInstall(input: { sessionId: string; organizationId: string }): Promise<{ url: string }>;
@ -130,9 +129,9 @@ interface AppOrganizationHandle {
} }
interface TaskHandle { interface TaskHandle {
getTaskSummary(): Promise<WorkbenchTaskSummary>; getTaskSummary(): Promise<WorkspaceTaskSummary>;
getTaskDetail(): Promise<WorkbenchTaskDetail>; getTaskDetail(): Promise<WorkspaceTaskDetail>;
getSessionDetail(input: { sessionId: string }): Promise<WorkbenchSessionDetail>; getSessionDetail(input: { sessionId: string }): Promise<WorkspaceSessionDetail>;
connect(): ActorConn; connect(): ActorConn;
} }
@ -192,6 +191,7 @@ export interface BackendClient {
skipAppStarterRepo(): Promise<FoundryAppSnapshot>; skipAppStarterRepo(): Promise<FoundryAppSnapshot>;
starAppStarterRepo(organizationId: string): Promise<FoundryAppSnapshot>; starAppStarterRepo(organizationId: string): Promise<FoundryAppSnapshot>;
selectAppOrganization(organizationId: string): Promise<FoundryAppSnapshot>; selectAppOrganization(organizationId: string): Promise<FoundryAppSnapshot>;
setAppDefaultModel(defaultModel: WorkspaceModelId): Promise<FoundryAppSnapshot>;
updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<FoundryAppSnapshot>; updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<FoundryAppSnapshot>;
triggerAppRepoImport(organizationId: string): Promise<FoundryAppSnapshot>; triggerAppRepoImport(organizationId: string): Promise<FoundryAppSnapshot>;
reconnectAppGithub(organizationId: string): Promise<void>; reconnectAppGithub(organizationId: string): Promise<void>;
@ -204,11 +204,11 @@ export interface BackendClient {
createTask(input: CreateTaskInput): Promise<TaskRecord>; createTask(input: CreateTaskInput): Promise<TaskRecord>;
listTasks(organizationId: string, repoId?: string): Promise<TaskSummary[]>; listTasks(organizationId: string, repoId?: string): Promise<TaskSummary[]>;
getRepoOverview(organizationId: string, repoId: string): Promise<RepoOverview>; getRepoOverview(organizationId: string, repoId: string): Promise<RepoOverview>;
getTask(organizationId: string, taskId: string): Promise<TaskRecord>; getTask(organizationId: string, repoId: string, taskId: string): Promise<TaskRecord>;
listHistory(input: HistoryQueryInput): Promise<HistoryEvent[]>; listHistory(input: HistoryQueryInput): Promise<HistoryEvent[]>;
switchTask(organizationId: string, taskId: string): Promise<SwitchResult>; switchTask(organizationId: string, repoId: string, taskId: string): Promise<SwitchResult>;
attachTask(organizationId: string, taskId: string): Promise<{ target: string; sessionId: string | null }>; attachTask(organizationId: string, repoId: string, taskId: string): Promise<{ target: string; sessionId: string | null }>;
runAction(organizationId: string, taskId: string, action: TaskAction): Promise<void>; runAction(organizationId: string, repoId: string, taskId: string, action: TaskAction): Promise<void>;
createSandboxSession(input: { createSandboxSession(input: {
organizationId: string; organizationId: string;
sandboxProviderId: SandboxProviderId; sandboxProviderId: SandboxProviderId;
@ -280,28 +280,27 @@ export interface BackendClient {
): Promise<{ sandboxProviderId: SandboxProviderId; sandboxId: string; state: string; at: number }>; ): Promise<{ sandboxProviderId: SandboxProviderId; sandboxId: string; state: string; at: number }>;
getSandboxAgentConnection(organizationId: string, sandboxProviderId: SandboxProviderId, sandboxId: string): Promise<{ endpoint: string; token?: string }>; getSandboxAgentConnection(organizationId: string, sandboxProviderId: SandboxProviderId, sandboxId: string): Promise<{ endpoint: string; token?: string }>;
getOrganizationSummary(organizationId: string): Promise<OrganizationSummarySnapshot>; getOrganizationSummary(organizationId: string): Promise<OrganizationSummarySnapshot>;
getTaskDetail(organizationId: string, repoId: string, taskId: string): Promise<WorkbenchTaskDetail>; getTaskDetail(organizationId: string, repoId: string, taskId: string): Promise<WorkspaceTaskDetail>;
getSessionDetail(organizationId: string, repoId: string, taskId: string, sessionId: string): Promise<WorkbenchSessionDetail>; getSessionDetail(organizationId: string, repoId: string, taskId: string, sessionId: string): Promise<WorkspaceSessionDetail>;
getWorkbench(organizationId: string): Promise<TaskWorkbenchSnapshot>; getWorkspace(organizationId: string): Promise<TaskWorkspaceSnapshot>;
subscribeWorkbench(organizationId: string, listener: () => void): () => void; subscribeWorkspace(organizationId: string, listener: () => void): () => void;
createWorkbenchTask(organizationId: string, input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>; createWorkspaceTask(organizationId: string, input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse>;
markWorkbenchUnread(organizationId: string, input: TaskWorkbenchSelectInput): Promise<void>; markWorkspaceUnread(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void>;
renameWorkbenchTask(organizationId: string, input: TaskWorkbenchRenameInput): Promise<void>; renameWorkspaceTask(organizationId: string, input: TaskWorkspaceRenameInput): Promise<void>;
renameWorkbenchBranch(organizationId: string, input: TaskWorkbenchRenameInput): Promise<void>; createWorkspaceSession(organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }>;
createWorkbenchSession(organizationId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }>; renameWorkspaceSession(organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise<void>;
renameWorkbenchSession(organizationId: string, input: TaskWorkbenchRenameSessionInput): Promise<void>; setWorkspaceSessionUnread(organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
setWorkbenchSessionUnread(organizationId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise<void>; updateWorkspaceDraft(organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise<void>;
updateWorkbenchDraft(organizationId: string, input: TaskWorkbenchUpdateDraftInput): Promise<void>; changeWorkspaceModel(organizationId: string, input: TaskWorkspaceChangeModelInput): Promise<void>;
changeWorkbenchModel(organizationId: string, input: TaskWorkbenchChangeModelInput): Promise<void>; sendWorkspaceMessage(organizationId: string, input: TaskWorkspaceSendMessageInput): Promise<void>;
sendWorkbenchMessage(organizationId: string, input: TaskWorkbenchSendMessageInput): Promise<void>; stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void>;
stopWorkbenchSession(organizationId: string, input: TaskWorkbenchSessionInput): Promise<void>; closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void>;
closeWorkbenchSession(organizationId: string, input: TaskWorkbenchSessionInput): Promise<void>; publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void>;
publishWorkbenchPr(organizationId: string, input: TaskWorkbenchSelectInput): Promise<void>; revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void>;
revertWorkbenchFile(organizationId: string, input: TaskWorkbenchDiffInput): Promise<void>; adminReloadGithubOrganization(organizationId: string): Promise<void>;
reloadGithubOrganization(organizationId: string): Promise<void>; adminReloadGithubPullRequests(organizationId: string): Promise<void>;
reloadGithubPullRequests(organizationId: string): Promise<void>; adminReloadGithubRepository(organizationId: string, repoId: string): Promise<void>;
reloadGithubRepository(organizationId: string, repoId: string): Promise<void>; adminReloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise<void>;
reloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise<void>;
health(): Promise<{ ok: true }>; health(): Promise<{ ok: true }>;
useOrganization(organizationId: string): Promise<{ organizationId: string }>; useOrganization(organizationId: string): Promise<{ organizationId: string }>;
starSandboxAgentRepo(organizationId: string): Promise<StarSandboxAgentRepoResult>; starSandboxAgentRepo(organizationId: string): Promise<StarSandboxAgentRepoResult>;
@ -410,7 +409,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
const rivetApiEndpoint = endpoints.rivetEndpoint; const rivetApiEndpoint = endpoints.rivetEndpoint;
const appApiEndpoint = endpoints.appEndpoint; const appApiEndpoint = endpoints.appEndpoint;
const client = createClient({ endpoint: rivetApiEndpoint }) as unknown as RivetClient; const client = createClient({ endpoint: rivetApiEndpoint }) as unknown as RivetClient;
const workbenchSubscriptions = new Map< const workspaceSubscriptions = new Map<
string, string,
{ {
listeners: Set<() => void>; listeners: Set<() => void>;
@ -563,7 +562,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
} }
}; };
const getWorkbenchCompat = async (organizationId: string): Promise<TaskWorkbenchSnapshot> => { const getWorkspaceCompat = async (organizationId: string): Promise<TaskWorkspaceSnapshot> => {
const summary = await (await organization(organizationId)).getOrganizationSummary({ organizationId }); const summary = await (await organization(organizationId)).getOrganizationSummary({ organizationId });
const tasks = ( const tasks = (
await Promise.all( await Promise.all(
@ -590,7 +589,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
} }
}), }),
); );
const sessionDetailsById = new Map(sessionDetails.filter((entry): entry is readonly [string, WorkbenchSessionDetail] => entry !== null)); const sessionDetailsById = new Map(sessionDetails.filter((entry): entry is readonly [string, WorkspaceSessionDetail] => entry !== null));
return { return {
id: detail.id, id: detail.id,
repoId: detail.repoId, repoId: detail.repoId,
@ -623,7 +622,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}; };
}), }),
) )
).filter((task): task is TaskWorkbenchSnapshot["tasks"][number] => task !== null); ).filter((task): task is TaskWorkspaceSnapshot["tasks"][number] => task !== null);
const repositories = summary.repos const repositories = summary.repos
.map((repo) => ({ .map((repo) => ({
@ -642,14 +641,14 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}; };
}; };
const subscribeWorkbench = (organizationId: string, listener: () => void): (() => void) => { const subscribeWorkspace = (organizationId: string, listener: () => void): (() => void) => {
let entry = workbenchSubscriptions.get(organizationId); let entry = workspaceSubscriptions.get(organizationId);
if (!entry) { if (!entry) {
entry = { entry = {
listeners: new Set(), listeners: new Set(),
disposeConnPromise: null, disposeConnPromise: null,
}; };
workbenchSubscriptions.set(organizationId, entry); workspaceSubscriptions.set(organizationId, entry);
} }
entry.listeners.add(listener); entry.listeners.add(listener);
@ -658,8 +657,8 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
entry.disposeConnPromise = (async () => { entry.disposeConnPromise = (async () => {
const handle = await organization(organizationId); const handle = await organization(organizationId);
const conn = (handle as any).connect(); const conn = (handle as any).connect();
const unsubscribeEvent = conn.on("workbenchUpdated", () => { const unsubscribeEvent = conn.on("organizationUpdated", () => {
const current = workbenchSubscriptions.get(organizationId); const current = workspaceSubscriptions.get(organizationId);
if (!current) { if (!current) {
return; return;
} }
@ -677,7 +676,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
} }
return () => { return () => {
const current = workbenchSubscriptions.get(organizationId); const current = workspaceSubscriptions.get(organizationId);
if (!current) { if (!current) {
return; return;
} }
@ -686,7 +685,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return; return;
} }
workbenchSubscriptions.delete(organizationId); workspaceSubscriptions.delete(organizationId);
void current.disposeConnPromise?.then(async (disposeConn) => { void current.disposeConnPromise?.then(async (disposeConn) => {
await disposeConn?.(); await disposeConn?.();
}); });
@ -849,6 +848,14 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return await (await appOrganization()).selectAppOrganization({ sessionId, organizationId }); return await (await appOrganization()).selectAppOrganization({ sessionId, organizationId });
}, },
async setAppDefaultModel(defaultModel: WorkspaceModelId): Promise<FoundryAppSnapshot> {
const sessionId = await getSessionId();
if (!sessionId) {
throw new Error("No active auth session");
}
return await (await appOrganization()).setAppDefaultModel({ sessionId, defaultModel });
},
async updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<FoundryAppSnapshot> { async updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<FoundryAppSnapshot> {
const sessionId = await getSessionId(); const sessionId = await getSessionId();
if (!sessionId) { if (!sessionId) {
@ -948,33 +955,36 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return (await organization(organizationId)).getRepoOverview({ organizationId, repoId }); return (await organization(organizationId)).getRepoOverview({ organizationId, repoId });
}, },
async getTask(organizationId: string, taskId: string): Promise<TaskRecord> { async getTask(organizationId: string, repoId: string, taskId: string): Promise<TaskRecord> {
return (await organization(organizationId)).getTask({ return (await organization(organizationId)).getTask({
organizationId, organizationId,
repoId,
taskId, taskId,
}); });
}, },
async listHistory(input: HistoryQueryInput): Promise<HistoryEvent[]> { async listHistory(input: HistoryQueryInput): Promise<HistoryEvent[]> {
return (await organization(input.organizationId)).history(input); return (await organization(input.organizationId)).auditLog(input);
}, },
async switchTask(organizationId: string, taskId: string): Promise<SwitchResult> { async switchTask(organizationId: string, repoId: string, taskId: string): Promise<SwitchResult> {
return (await organization(organizationId)).switchTask(taskId); return (await organization(organizationId)).switchTask({ repoId, taskId });
}, },
async attachTask(organizationId: string, taskId: string): Promise<{ target: string; sessionId: string | null }> { async attachTask(organizationId: string, repoId: string, taskId: string): Promise<{ target: string; sessionId: string | null }> {
return (await organization(organizationId)).attachTask({ return (await organization(organizationId)).attachTask({
organizationId, organizationId,
repoId,
taskId, taskId,
reason: "cli.attach", reason: "cli.attach",
}); });
}, },
async runAction(organizationId: string, taskId: string, action: TaskAction): Promise<void> { async runAction(organizationId: string, repoId: string, taskId: string, action: TaskAction): Promise<void> {
if (action === "push") { if (action === "push") {
await (await organization(organizationId)).pushTask({ await (await organization(organizationId)).pushTask({
organizationId, organizationId,
repoId,
taskId, taskId,
reason: "cli.push", reason: "cli.push",
}); });
@ -983,6 +993,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
if (action === "sync") { if (action === "sync") {
await (await organization(organizationId)).syncTask({ await (await organization(organizationId)).syncTask({
organizationId, organizationId,
repoId,
taskId, taskId,
reason: "cli.sync", reason: "cli.sync",
}); });
@ -991,6 +1002,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
if (action === "merge") { if (action === "merge") {
await (await organization(organizationId)).mergeTask({ await (await organization(organizationId)).mergeTask({
organizationId, organizationId,
repoId,
taskId, taskId,
reason: "cli.merge", reason: "cli.merge",
}); });
@ -999,6 +1011,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
if (action === "archive") { if (action === "archive") {
await (await organization(organizationId)).archiveTask({ await (await organization(organizationId)).archiveTask({
organizationId, organizationId,
repoId,
taskId, taskId,
reason: "cli.archive", reason: "cli.archive",
}); });
@ -1006,6 +1019,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
} }
await (await organization(organizationId)).killTask({ await (await organization(organizationId)).killTask({
organizationId, organizationId,
repoId,
taskId, taskId,
reason: "cli.kill", reason: "cli.kill",
}); });
@ -1160,92 +1174,88 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return (await organization(organizationId)).getOrganizationSummary({ organizationId }); return (await organization(organizationId)).getOrganizationSummary({ organizationId });
}, },
async getTaskDetail(organizationId: string, repoId: string, taskIdValue: string): Promise<WorkbenchTaskDetail> { async getTaskDetail(organizationId: string, repoId: string, taskIdValue: string): Promise<WorkspaceTaskDetail> {
return (await task(organizationId, repoId, taskIdValue)).getTaskDetail(); return (await task(organizationId, repoId, taskIdValue)).getTaskDetail();
}, },
async getSessionDetail(organizationId: string, repoId: string, taskIdValue: string, sessionId: string): Promise<WorkbenchSessionDetail> { async getSessionDetail(organizationId: string, repoId: string, taskIdValue: string, sessionId: string): Promise<WorkspaceSessionDetail> {
return (await task(organizationId, repoId, taskIdValue)).getSessionDetail({ sessionId }); return (await task(organizationId, repoId, taskIdValue)).getSessionDetail({ sessionId });
}, },
async getWorkbench(organizationId: string): Promise<TaskWorkbenchSnapshot> { async getWorkspace(organizationId: string): Promise<TaskWorkspaceSnapshot> {
return await getWorkbenchCompat(organizationId); return await getWorkspaceCompat(organizationId);
}, },
subscribeWorkbench(organizationId: string, listener: () => void): () => void { subscribeWorkspace(organizationId: string, listener: () => void): () => void {
return subscribeWorkbench(organizationId, listener); return subscribeWorkspace(organizationId, listener);
}, },
async createWorkbenchTask(organizationId: string, input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> { async createWorkspaceTask(organizationId: string, input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse> {
return (await organization(organizationId)).createWorkbenchTask(input); return (await organization(organizationId)).createWorkspaceTask(input);
}, },
async markWorkbenchUnread(organizationId: string, input: TaskWorkbenchSelectInput): Promise<void> { async markWorkspaceUnread(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await (await organization(organizationId)).markWorkbenchUnread(input); await (await organization(organizationId)).markWorkspaceUnread(input);
}, },
async renameWorkbenchTask(organizationId: string, input: TaskWorkbenchRenameInput): Promise<void> { async renameWorkspaceTask(organizationId: string, input: TaskWorkspaceRenameInput): Promise<void> {
await (await organization(organizationId)).renameWorkbenchTask(input); await (await organization(organizationId)).renameWorkspaceTask(input);
}, },
async renameWorkbenchBranch(organizationId: string, input: TaskWorkbenchRenameInput): Promise<void> { async createWorkspaceSession(organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
await (await organization(organizationId)).renameWorkbenchBranch(input); return await (await organization(organizationId)).createWorkspaceSession(input);
}, },
async createWorkbenchSession(organizationId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }> { async renameWorkspaceSession(organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise<void> {
return await (await organization(organizationId)).createWorkbenchSession(input); await (await organization(organizationId)).renameWorkspaceSession(input);
}, },
async renameWorkbenchSession(organizationId: string, input: TaskWorkbenchRenameSessionInput): Promise<void> { async setWorkspaceSessionUnread(organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await (await organization(organizationId)).renameWorkbenchSession(input); await (await organization(organizationId)).setWorkspaceSessionUnread(input);
}, },
async setWorkbenchSessionUnread(organizationId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> { async updateWorkspaceDraft(organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
await (await organization(organizationId)).setWorkbenchSessionUnread(input); await (await organization(organizationId)).updateWorkspaceDraft(input);
}, },
async updateWorkbenchDraft(organizationId: string, input: TaskWorkbenchUpdateDraftInput): Promise<void> { async changeWorkspaceModel(organizationId: string, input: TaskWorkspaceChangeModelInput): Promise<void> {
await (await organization(organizationId)).updateWorkbenchDraft(input); await (await organization(organizationId)).changeWorkspaceModel(input);
}, },
async changeWorkbenchModel(organizationId: string, input: TaskWorkbenchChangeModelInput): Promise<void> { async sendWorkspaceMessage(organizationId: string, input: TaskWorkspaceSendMessageInput): Promise<void> {
await (await organization(organizationId)).changeWorkbenchModel(input); await (await organization(organizationId)).sendWorkspaceMessage(input);
}, },
async sendWorkbenchMessage(organizationId: string, input: TaskWorkbenchSendMessageInput): Promise<void> { async stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await (await organization(organizationId)).sendWorkbenchMessage(input); await (await organization(organizationId)).stopWorkspaceSession(input);
}, },
async stopWorkbenchSession(organizationId: string, input: TaskWorkbenchSessionInput): Promise<void> { async closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await (await organization(organizationId)).stopWorkbenchSession(input); await (await organization(organizationId)).closeWorkspaceSession(input);
}, },
async closeWorkbenchSession(organizationId: string, input: TaskWorkbenchSessionInput): Promise<void> { async publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await (await organization(organizationId)).closeWorkbenchSession(input); await (await organization(organizationId)).publishWorkspacePr(input);
}, },
async publishWorkbenchPr(organizationId: string, input: TaskWorkbenchSelectInput): Promise<void> { async revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void> {
await (await organization(organizationId)).publishWorkbenchPr(input); await (await organization(organizationId)).revertWorkspaceFile(input);
}, },
async revertWorkbenchFile(organizationId: string, input: TaskWorkbenchDiffInput): Promise<void> { async adminReloadGithubOrganization(organizationId: string): Promise<void> {
await (await organization(organizationId)).revertWorkbenchFile(input); await (await organization(organizationId)).adminReloadGithubOrganization();
}, },
async reloadGithubOrganization(organizationId: string): Promise<void> { async adminReloadGithubPullRequests(organizationId: string): Promise<void> {
await (await organization(organizationId)).reloadGithubOrganization(); await (await organization(organizationId)).adminReloadGithubPullRequests();
}, },
async reloadGithubPullRequests(organizationId: string): Promise<void> { async adminReloadGithubRepository(organizationId: string, repoId: string): Promise<void> {
await (await organization(organizationId)).reloadGithubPullRequests(); await (await organization(organizationId)).adminReloadGithubRepository({ repoId });
}, },
async reloadGithubRepository(organizationId: string, repoId: string): Promise<void> { async adminReloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise<void> {
await (await organization(organizationId)).reloadGithubRepository({ repoId }); await (await organization(organizationId)).adminReloadGithubPullRequest({ repoId, prNumber });
},
async reloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise<void> {
await (await organization(organizationId)).reloadGithubPullRequest({ repoId, prNumber });
}, },
async health(): Promise<{ ok: true }> { async health(): Promise<{ ok: true }> {

View file

@ -8,4 +8,4 @@ export * from "./subscription/use-subscription.js";
export * from "./keys.js"; export * from "./keys.js";
export * from "./mock-app.js"; export * from "./mock-app.js";
export * from "./view-model.js"; export * from "./view-model.js";
export * from "./workbench-client.js"; export * from "./workspace-client.js";

View file

@ -16,6 +16,6 @@ export function taskSandboxKey(organizationId: string, sandboxId: string): Actor
return ["org", organizationId, "sandbox", sandboxId]; return ["org", organizationId, "sandbox", sandboxId];
} }
export function historyKey(organizationId: string, repoId: string): ActorKey { export function auditLogKey(organizationId: string, repoId: string): ActorKey {
return ["org", organizationId, "repository", repoId, "history"]; return ["org", organizationId, "repository", repoId, "audit-log"];
} }

View file

@ -1,4 +1,4 @@
import type { WorkbenchModelId } from "@sandbox-agent/foundry-shared"; import type { WorkspaceModelId } from "@sandbox-agent/foundry-shared";
import { injectMockLatency } from "./mock/latency.js"; import { injectMockLatency } from "./mock/latency.js";
import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" }; import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" };
@ -16,6 +16,7 @@ export interface MockFoundryUser {
githubLogin: string; githubLogin: string;
roleLabel: string; roleLabel: string;
eligibleOrganizationIds: string[]; eligibleOrganizationIds: string[];
defaultModel: WorkspaceModelId;
} }
export interface MockFoundryOrganizationMember { export interface MockFoundryOrganizationMember {
@ -61,7 +62,6 @@ export interface MockFoundryOrganizationSettings {
slug: string; slug: string;
primaryDomain: string; primaryDomain: string;
seatAccrualMode: "first_prompt"; seatAccrualMode: "first_prompt";
defaultModel: WorkbenchModelId;
autoImportRepos: boolean; autoImportRepos: boolean;
} }
@ -111,6 +111,7 @@ export interface MockFoundryAppClient {
skipStarterRepo(): Promise<void>; skipStarterRepo(): Promise<void>;
starStarterRepo(organizationId: string): Promise<void>; starStarterRepo(organizationId: string): Promise<void>;
selectOrganization(organizationId: string): Promise<void>; selectOrganization(organizationId: string): Promise<void>;
setDefaultModel(model: WorkspaceModelId): Promise<void>;
updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void>; updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void>;
triggerGithubSync(organizationId: string): Promise<void>; triggerGithubSync(organizationId: string): Promise<void>;
completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void>; completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void>;
@ -180,7 +181,6 @@ function buildRivetOrganization(): MockFoundryOrganization {
slug: "rivet", slug: "rivet",
primaryDomain: "rivet.dev", primaryDomain: "rivet.dev",
seatAccrualMode: "first_prompt", seatAccrualMode: "first_prompt",
defaultModel: "gpt-5.3-codex",
autoImportRepos: true, autoImportRepos: true,
}, },
github: { github: {
@ -233,6 +233,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "nathan", githubLogin: "nathan",
roleLabel: "Founder", roleLabel: "Founder",
eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"], eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"],
defaultModel: "gpt-5.3-codex",
}, },
{ {
id: "user-maya", id: "user-maya",
@ -241,6 +242,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "maya", githubLogin: "maya",
roleLabel: "Staff Engineer", roleLabel: "Staff Engineer",
eligibleOrganizationIds: ["acme"], eligibleOrganizationIds: ["acme"],
defaultModel: "claude-sonnet-4",
}, },
{ {
id: "user-jamie", id: "user-jamie",
@ -249,6 +251,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "jamie", githubLogin: "jamie",
roleLabel: "Platform Lead", roleLabel: "Platform Lead",
eligibleOrganizationIds: ["personal-jamie", "rivet"], eligibleOrganizationIds: ["personal-jamie", "rivet"],
defaultModel: "claude-opus-4",
}, },
], ],
organizations: [ organizations: [
@ -261,7 +264,6 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
slug: "nathan", slug: "nathan",
primaryDomain: "personal", primaryDomain: "personal",
seatAccrualMode: "first_prompt", seatAccrualMode: "first_prompt",
defaultModel: "claude-sonnet-4",
autoImportRepos: true, autoImportRepos: true,
}, },
github: { github: {
@ -297,7 +299,6 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
slug: "acme", slug: "acme",
primaryDomain: "acme.dev", primaryDomain: "acme.dev",
seatAccrualMode: "first_prompt", seatAccrualMode: "first_prompt",
defaultModel: "claude-sonnet-4",
autoImportRepos: true, autoImportRepos: true,
}, },
github: { github: {
@ -342,7 +343,6 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
slug: "jamie", slug: "jamie",
primaryDomain: "personal", primaryDomain: "personal",
seatAccrualMode: "first_prompt", seatAccrualMode: "first_prompt",
defaultModel: "claude-opus-4",
autoImportRepos: true, autoImportRepos: true,
}, },
github: { github: {
@ -538,6 +538,18 @@ class MockFoundryAppStore implements MockFoundryAppClient {
} }
} }
async setDefaultModel(model: WorkspaceModelId): Promise<void> {
await this.injectAsyncLatency();
const currentUserId = this.snapshot.auth.currentUserId;
if (!currentUserId) {
throw new Error("No signed-in mock user");
}
this.updateSnapshot((current) => ({
...current,
users: current.users.map((user) => (user.id === currentUserId ? { ...user, defaultModel: model } : user)),
}));
}
async updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void> { async updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void> {
await this.injectAsyncLatency(); await this.injectAsyncLatency();
this.requireOrganization(input.organizationId); this.requireOrganization(input.organizationId);

View file

@ -6,25 +6,25 @@ import type {
SessionEvent, SessionEvent,
TaskRecord, TaskRecord,
TaskSummary, TaskSummary,
TaskWorkbenchChangeModelInput, TaskWorkspaceChangeModelInput,
TaskWorkbenchCreateTaskInput, TaskWorkspaceCreateTaskInput,
TaskWorkbenchCreateTaskResponse, TaskWorkspaceCreateTaskResponse,
TaskWorkbenchDiffInput, TaskWorkspaceDiffInput,
TaskWorkbenchRenameInput, TaskWorkspaceRenameInput,
TaskWorkbenchRenameSessionInput, TaskWorkspaceRenameSessionInput,
TaskWorkbenchSelectInput, TaskWorkspaceSelectInput,
TaskWorkbenchSetSessionUnreadInput, TaskWorkspaceSetSessionUnreadInput,
TaskWorkbenchSendMessageInput, TaskWorkspaceSendMessageInput,
TaskWorkbenchSnapshot, TaskWorkspaceSnapshot,
TaskWorkbenchSessionInput, TaskWorkspaceSessionInput,
TaskWorkbenchUpdateDraftInput, TaskWorkspaceUpdateDraftInput,
TaskEvent, TaskEvent,
WorkbenchSessionDetail, WorkspaceSessionDetail,
WorkbenchTaskDetail, WorkspaceTaskDetail,
WorkbenchTaskSummary, WorkspaceTaskSummary,
OrganizationEvent, OrganizationEvent,
OrganizationSummarySnapshot, OrganizationSummarySnapshot,
HistoryEvent, AuditLogEvent as HistoryEvent,
HistoryQueryInput, HistoryQueryInput,
SandboxProviderId, SandboxProviderId,
RepoOverview, RepoOverview,
@ -34,7 +34,7 @@ import type {
} from "@sandbox-agent/foundry-shared"; } from "@sandbox-agent/foundry-shared";
import type { ProcessCreateRequest, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent"; import type { ProcessCreateRequest, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
import type { ActorConn, BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js"; import type { ActorConn, BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js";
import { getSharedMockWorkbenchClient } from "./workbench-client.js"; import { getSharedMockWorkspaceClient } from "./workspace-client.js";
interface MockProcessRecord extends SandboxProcessRecord { interface MockProcessRecord extends SandboxProcessRecord {
logText: string; logText: string;
@ -89,7 +89,7 @@ function toTaskStatus(status: TaskRecord["status"], archived: boolean): TaskReco
} }
export function createMockBackendClient(defaultOrganizationId = "default"): BackendClient { export function createMockBackendClient(defaultOrganizationId = "default"): BackendClient {
const workbench = getSharedMockWorkbenchClient(); const workspace = getSharedMockWorkspaceClient();
const listenersBySandboxId = new Map<string, Set<() => void>>(); const listenersBySandboxId = new Map<string, Set<() => void>>();
const processesBySandboxId = new Map<string, MockProcessRecord[]>(); const processesBySandboxId = new Map<string, MockProcessRecord[]>();
const connectionListeners = new Map<string, Set<(payload: any) => void>>(); const connectionListeners = new Map<string, Set<(payload: any) => void>>();
@ -97,7 +97,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
let nextProcessId = 1; let nextProcessId = 1;
const requireTask = (taskId: string) => { const requireTask = (taskId: string) => {
const task = workbench.getSnapshot().tasks.find((candidate) => candidate.id === taskId); const task = workspace.getSnapshot().tasks.find((candidate) => candidate.id === taskId);
if (!task) { if (!task) {
throw new Error(`Unknown mock task ${taskId}`); throw new Error(`Unknown mock task ${taskId}`);
} }
@ -164,7 +164,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
async dispose(): Promise<void> {}, async dispose(): Promise<void> {},
}); });
const buildTaskSummary = (task: TaskWorkbenchSnapshot["tasks"][number]): WorkbenchTaskSummary => ({ const buildTaskSummary = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskSummary => ({
id: task.id, id: task.id,
repoId: task.repoId, repoId: task.repoId,
title: task.title, title: task.title,
@ -187,7 +187,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
})), })),
}); });
const buildTaskDetail = (task: TaskWorkbenchSnapshot["tasks"][number]): WorkbenchTaskDetail => ({ const buildTaskDetail = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskDetail => ({
...buildTaskSummary(task), ...buildTaskSummary(task),
task: task.title, task: task.title,
agentType: task.sessions[0]?.agent === "Codex" ? "codex" : "claude", agentType: task.sessions[0]?.agent === "Codex" ? "codex" : "claude",
@ -211,7 +211,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
activeSandboxId: task.id, activeSandboxId: task.id,
}); });
const buildSessionDetail = (task: TaskWorkbenchSnapshot["tasks"][number], sessionId: string): WorkbenchSessionDetail => { const buildSessionDetail = (task: TaskWorkspaceSnapshot["tasks"][number], sessionId: string): WorkspaceSessionDetail => {
const tab = task.sessions.find((candidate) => candidate.id === sessionId); const tab = task.sessions.find((candidate) => candidate.id === sessionId);
if (!tab) { if (!tab) {
throw new Error(`Unknown mock session ${sessionId} for task ${task.id}`); throw new Error(`Unknown mock session ${sessionId} for task ${task.id}`);
@ -232,7 +232,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
}; };
const buildOrganizationSummary = (): OrganizationSummarySnapshot => { const buildOrganizationSummary = (): OrganizationSummarySnapshot => {
const snapshot = workbench.getSnapshot(); const snapshot = workspace.getSnapshot();
const taskSummaries = snapshot.tasks.map(buildTaskSummary); const taskSummaries = snapshot.tasks.map(buildTaskSummary);
return { return {
organizationId: defaultOrganizationId, organizationId: defaultOrganizationId,
@ -256,20 +256,16 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
`sandbox:${organizationId}:${sandboxProviderId}:${sandboxId}`; `sandbox:${organizationId}:${sandboxProviderId}:${sandboxId}`;
const emitOrganizationSnapshot = (): void => { const emitOrganizationSnapshot = (): void => {
const summary = buildOrganizationSummary();
const latestTask = [...summary.taskSummaries].sort((left, right) => right.updatedAtMs - left.updatedAtMs)[0] ?? null;
if (latestTask) {
emitConnectionEvent(organizationScope(defaultOrganizationId), "organizationUpdated", { emitConnectionEvent(organizationScope(defaultOrganizationId), "organizationUpdated", {
type: "taskSummaryUpdated", type: "organizationUpdated",
taskSummary: latestTask, snapshot: buildOrganizationSummary(),
} satisfies OrganizationEvent); } satisfies OrganizationEvent);
}
}; };
const emitTaskUpdate = (taskId: string): void => { const emitTaskUpdate = (taskId: string): void => {
const task = requireTask(taskId); const task = requireTask(taskId);
emitConnectionEvent(taskScope(defaultOrganizationId, task.repoId, task.id), "taskUpdated", { emitConnectionEvent(taskScope(defaultOrganizationId, task.repoId, task.id), "taskUpdated", {
type: "taskDetailUpdated", type: "taskUpdated",
detail: buildTaskDetail(task), detail: buildTaskDetail(task),
} satisfies TaskEvent); } satisfies TaskEvent);
}; };
@ -400,6 +396,10 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
return unsupportedAppSnapshot(); return unsupportedAppSnapshot();
}, },
async setAppDefaultModel(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async updateAppOrganizationProfile(): Promise<FoundryAppSnapshot> { async updateAppOrganizationProfile(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot(); return unsupportedAppSnapshot();
}, },
@ -433,7 +433,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
}, },
async listRepos(_organizationId: string): Promise<RepoRecord[]> { async listRepos(_organizationId: string): Promise<RepoRecord[]> {
return workbench.getSnapshot().repos.map((repo) => ({ return workspace.getSnapshot().repos.map((repo) => ({
organizationId: defaultOrganizationId, organizationId: defaultOrganizationId,
repoId: repo.id, repoId: repo.id,
remoteUrl: mockRepoRemote(repo.label), remoteUrl: mockRepoRemote(repo.label),
@ -447,7 +447,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
}, },
async listTasks(_organizationId: string, repoId?: string): Promise<TaskSummary[]> { async listTasks(_organizationId: string, repoId?: string): Promise<TaskSummary[]> {
return workbench return workspace
.getSnapshot() .getSnapshot()
.tasks.filter((task) => !repoId || task.repoId === repoId) .tasks.filter((task) => !repoId || task.repoId === repoId)
.map((task) => ({ .map((task) => ({
@ -641,24 +641,24 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
return buildOrganizationSummary(); return buildOrganizationSummary();
}, },
async getTaskDetail(_organizationId: string, _repoId: string, taskId: string): Promise<WorkbenchTaskDetail> { async getTaskDetail(_organizationId: string, _repoId: string, taskId: string): Promise<WorkspaceTaskDetail> {
return buildTaskDetail(requireTask(taskId)); return buildTaskDetail(requireTask(taskId));
}, },
async getSessionDetail(_organizationId: string, _repoId: string, taskId: string, sessionId: string): Promise<WorkbenchSessionDetail> { async getSessionDetail(_organizationId: string, _repoId: string, taskId: string, sessionId: string): Promise<WorkspaceSessionDetail> {
return buildSessionDetail(requireTask(taskId), sessionId); return buildSessionDetail(requireTask(taskId), sessionId);
}, },
async getWorkbench(): Promise<TaskWorkbenchSnapshot> { async getWorkspace(): Promise<TaskWorkspaceSnapshot> {
return workbench.getSnapshot(); return workspace.getSnapshot();
}, },
subscribeWorkbench(_organizationId: string, listener: () => void): () => void { subscribeWorkspace(_organizationId: string, listener: () => void): () => void {
return workbench.subscribe(listener); return workspace.subscribe(listener);
}, },
async createWorkbenchTask(_organizationId: string, input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> { async createWorkspaceTask(_organizationId: string, input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse> {
const created = await workbench.createTask(input); const created = await workspace.createTask(input);
emitOrganizationSnapshot(); emitOrganizationSnapshot();
emitTaskUpdate(created.taskId); emitTaskUpdate(created.taskId);
if (created.sessionId) { if (created.sessionId) {
@ -667,99 +667,93 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
return created; return created;
}, },
async markWorkbenchUnread(_organizationId: string, input: TaskWorkbenchSelectInput): Promise<void> { async markWorkspaceUnread(_organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await workbench.markTaskUnread(input); await workspace.markTaskUnread(input);
emitOrganizationSnapshot(); emitOrganizationSnapshot();
emitTaskUpdate(input.taskId); emitTaskUpdate(input.taskId);
}, },
async renameWorkbenchTask(_organizationId: string, input: TaskWorkbenchRenameInput): Promise<void> { async renameWorkspaceTask(_organizationId: string, input: TaskWorkspaceRenameInput): Promise<void> {
await workbench.renameTask(input); await workspace.renameTask(input);
emitOrganizationSnapshot(); emitOrganizationSnapshot();
emitTaskUpdate(input.taskId); emitTaskUpdate(input.taskId);
}, },
async renameWorkbenchBranch(_organizationId: string, input: TaskWorkbenchRenameInput): Promise<void> { async createWorkspaceSession(_organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
await workbench.renameBranch(input); const created = await workspace.addSession(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
},
async createWorkbenchSession(_organizationId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }> {
const created = await workbench.addSession(input);
emitOrganizationSnapshot(); emitOrganizationSnapshot();
emitTaskUpdate(input.taskId); emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, created.sessionId); emitSessionUpdate(input.taskId, created.sessionId);
return created; return created;
}, },
async renameWorkbenchSession(_organizationId: string, input: TaskWorkbenchRenameSessionInput): Promise<void> { async renameWorkspaceSession(_organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise<void> {
await workbench.renameSession(input); await workspace.renameSession(input);
emitOrganizationSnapshot(); emitOrganizationSnapshot();
emitTaskUpdate(input.taskId); emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId); emitSessionUpdate(input.taskId, input.sessionId);
}, },
async setWorkbenchSessionUnread(_organizationId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> { async setWorkspaceSessionUnread(_organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await workbench.setSessionUnread(input); await workspace.setSessionUnread(input);
emitOrganizationSnapshot(); emitOrganizationSnapshot();
emitTaskUpdate(input.taskId); emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId); emitSessionUpdate(input.taskId, input.sessionId);
}, },
async updateWorkbenchDraft(_organizationId: string, input: TaskWorkbenchUpdateDraftInput): Promise<void> { async updateWorkspaceDraft(_organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
await workbench.updateDraft(input); await workspace.updateDraft(input);
emitOrganizationSnapshot(); emitOrganizationSnapshot();
emitTaskUpdate(input.taskId); emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId); emitSessionUpdate(input.taskId, input.sessionId);
}, },
async changeWorkbenchModel(_organizationId: string, input: TaskWorkbenchChangeModelInput): Promise<void> { async changeWorkspaceModel(_organizationId: string, input: TaskWorkspaceChangeModelInput): Promise<void> {
await workbench.changeModel(input); await workspace.changeModel(input);
emitOrganizationSnapshot(); emitOrganizationSnapshot();
emitTaskUpdate(input.taskId); emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId); emitSessionUpdate(input.taskId, input.sessionId);
}, },
async sendWorkbenchMessage(_organizationId: string, input: TaskWorkbenchSendMessageInput): Promise<void> { async sendWorkspaceMessage(_organizationId: string, input: TaskWorkspaceSendMessageInput): Promise<void> {
await workbench.sendMessage(input); await workspace.sendMessage(input);
emitOrganizationSnapshot(); emitOrganizationSnapshot();
emitTaskUpdate(input.taskId); emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId); emitSessionUpdate(input.taskId, input.sessionId);
}, },
async stopWorkbenchSession(_organizationId: string, input: TaskWorkbenchSessionInput): Promise<void> { async stopWorkspaceSession(_organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await workbench.stopAgent(input); await workspace.stopAgent(input);
emitOrganizationSnapshot(); emitOrganizationSnapshot();
emitTaskUpdate(input.taskId); emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId); emitSessionUpdate(input.taskId, input.sessionId);
}, },
async closeWorkbenchSession(_organizationId: string, input: TaskWorkbenchSessionInput): Promise<void> { async closeWorkspaceSession(_organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await workbench.closeSession(input); await workspace.closeSession(input);
emitOrganizationSnapshot(); emitOrganizationSnapshot();
emitTaskUpdate(input.taskId); emitTaskUpdate(input.taskId);
}, },
async publishWorkbenchPr(_organizationId: string, input: TaskWorkbenchSelectInput): Promise<void> { async publishWorkspacePr(_organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await workbench.publishPr(input); await workspace.publishPr(input);
emitOrganizationSnapshot(); emitOrganizationSnapshot();
emitTaskUpdate(input.taskId); emitTaskUpdate(input.taskId);
}, },
async revertWorkbenchFile(_organizationId: string, input: TaskWorkbenchDiffInput): Promise<void> { async revertWorkspaceFile(_organizationId: string, input: TaskWorkspaceDiffInput): Promise<void> {
await workbench.revertFile(input); await workspace.revertFile(input);
emitOrganizationSnapshot(); emitOrganizationSnapshot();
emitTaskUpdate(input.taskId); emitTaskUpdate(input.taskId);
}, },
async reloadGithubOrganization(): Promise<void> {}, async adminReloadGithubOrganization(): Promise<void> {},
async reloadGithubPullRequests(): Promise<void> {}, async adminReloadGithubPullRequests(): Promise<void> {},
async reloadGithubRepository(): Promise<void> {}, async adminReloadGithubRepository(): Promise<void> {},
async reloadGithubPullRequest(): Promise<void> {}, async adminReloadGithubPullRequest(): Promise<void> {},
async health(): Promise<{ ok: true }> { async health(): Promise<{ ok: true }> {
return { ok: true }; return { ok: true };

View file

@ -1,33 +1,33 @@
import { import {
MODEL_GROUPS, MODEL_GROUPS,
buildInitialMockLayoutViewModel, buildInitialMockLayoutViewModel,
groupWorkbenchRepositories, groupWorkspaceRepositories,
nowMs, nowMs,
providerAgent, providerAgent,
randomReply, randomReply,
removeFileTreePath, removeFileTreePath,
slugify, slugify,
uid, uid,
} from "../workbench-model.js"; } from "../workspace-model.js";
import type { import type {
TaskWorkbenchAddSessionResponse, TaskWorkspaceAddSessionResponse,
TaskWorkbenchChangeModelInput, TaskWorkspaceChangeModelInput,
TaskWorkbenchCreateTaskInput, TaskWorkspaceCreateTaskInput,
TaskWorkbenchCreateTaskResponse, TaskWorkspaceCreateTaskResponse,
TaskWorkbenchDiffInput, TaskWorkspaceDiffInput,
TaskWorkbenchRenameInput, TaskWorkspaceRenameInput,
TaskWorkbenchRenameSessionInput, TaskWorkspaceRenameSessionInput,
TaskWorkbenchSelectInput, TaskWorkspaceSelectInput,
TaskWorkbenchSetSessionUnreadInput, TaskWorkspaceSetSessionUnreadInput,
TaskWorkbenchSendMessageInput, TaskWorkspaceSendMessageInput,
TaskWorkbenchSnapshot, TaskWorkspaceSnapshot,
TaskWorkbenchSessionInput, TaskWorkspaceSessionInput,
TaskWorkbenchUpdateDraftInput, TaskWorkspaceUpdateDraftInput,
WorkbenchSession as AgentSession, WorkspaceSession as AgentSession,
WorkbenchTask as Task, WorkspaceTask as Task,
WorkbenchTranscriptEvent as TranscriptEvent, WorkspaceTranscriptEvent as TranscriptEvent,
} from "@sandbox-agent/foundry-shared"; } from "@sandbox-agent/foundry-shared";
import type { TaskWorkbenchClient } from "../workbench-client.js"; import type { TaskWorkspaceClient } from "../workspace-client.js";
function buildTranscriptEvent(params: { function buildTranscriptEvent(params: {
sessionId: string; sessionId: string;
@ -47,12 +47,12 @@ function buildTranscriptEvent(params: {
}; };
} }
class MockWorkbenchStore implements TaskWorkbenchClient { class MockWorkspaceStore implements TaskWorkspaceClient {
private snapshot = buildInitialMockLayoutViewModel(); private snapshot = buildInitialMockLayoutViewModel();
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>(); private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
getSnapshot(): TaskWorkbenchSnapshot { getSnapshot(): TaskWorkspaceSnapshot {
return this.snapshot; return this.snapshot;
} }
@ -63,7 +63,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
}; };
} }
async createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> { async createTask(input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse> {
const id = uid(); const id = uid();
const sessionId = `session-${id}`; const sessionId = `session-${id}`;
const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId); const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId);
@ -109,7 +109,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
return { taskId: id, sessionId }; return { taskId: id, sessionId };
} }
async markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void> { async markTaskUnread(input: TaskWorkspaceSelectInput): Promise<void> {
this.updateTask(input.taskId, (task) => { this.updateTask(input.taskId, (task) => {
const targetSession = task.sessions[task.sessions.length - 1] ?? null; const targetSession = task.sessions[task.sessions.length - 1] ?? null;
if (!targetSession) { if (!targetSession) {
@ -123,7 +123,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
}); });
} }
async renameTask(input: TaskWorkbenchRenameInput): Promise<void> { async renameTask(input: TaskWorkspaceRenameInput): Promise<void> {
const value = input.value.trim(); const value = input.value.trim();
if (!value) { if (!value) {
throw new Error(`Cannot rename task ${input.taskId} to an empty title`); throw new Error(`Cannot rename task ${input.taskId} to an empty title`);
@ -131,19 +131,11 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
this.updateTask(input.taskId, (task) => ({ ...task, title: value, updatedAtMs: nowMs() })); this.updateTask(input.taskId, (task) => ({ ...task, title: value, updatedAtMs: nowMs() }));
} }
async renameBranch(input: TaskWorkbenchRenameInput): Promise<void> { async archiveTask(input: TaskWorkspaceSelectInput): Promise<void> {
const value = input.value.trim();
if (!value) {
throw new Error(`Cannot rename branch for task ${input.taskId} to an empty value`);
}
this.updateTask(input.taskId, (task) => ({ ...task, branch: value, updatedAtMs: nowMs() }));
}
async archiveTask(input: TaskWorkbenchSelectInput): Promise<void> {
this.updateTask(input.taskId, (task) => ({ ...task, status: "archived", updatedAtMs: nowMs() })); this.updateTask(input.taskId, (task) => ({ ...task, status: "archived", updatedAtMs: nowMs() }));
} }
async publishPr(input: TaskWorkbenchSelectInput): Promise<void> { async publishPr(input: TaskWorkspaceSelectInput): Promise<void> {
const nextPrNumber = Math.max(0, ...this.snapshot.tasks.map((task) => task.pullRequest?.number ?? 0)) + 1; const nextPrNumber = Math.max(0, ...this.snapshot.tasks.map((task) => task.pullRequest?.number ?? 0)) + 1;
this.updateTask(input.taskId, (task) => ({ this.updateTask(input.taskId, (task) => ({
...task, ...task,
@ -152,7 +144,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
})); }));
} }
async revertFile(input: TaskWorkbenchDiffInput): Promise<void> { async revertFile(input: TaskWorkspaceDiffInput): Promise<void> {
this.updateTask(input.taskId, (task) => { this.updateTask(input.taskId, (task) => {
const file = task.fileChanges.find((entry) => entry.path === input.path); const file = task.fileChanges.find((entry) => entry.path === input.path);
const nextDiffs = { ...task.diffs }; const nextDiffs = { ...task.diffs };
@ -167,7 +159,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
}); });
} }
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> { async updateDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void> {
this.assertSession(input.taskId, input.sessionId); this.assertSession(input.taskId, input.sessionId);
this.updateTask(input.taskId, (task) => ({ this.updateTask(input.taskId, (task) => ({
...task, ...task,
@ -187,7 +179,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
})); }));
} }
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> { async sendMessage(input: TaskWorkspaceSendMessageInput): Promise<void> {
const text = input.text.trim(); const text = input.text.trim();
if (!text) { if (!text) {
throw new Error(`Cannot send an empty mock prompt for task ${input.taskId}`); throw new Error(`Cannot send an empty mock prompt for task ${input.taskId}`);
@ -288,7 +280,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
this.pendingTimers.set(input.sessionId, timer); this.pendingTimers.set(input.sessionId, timer);
} }
async stopAgent(input: TaskWorkbenchSessionInput): Promise<void> { async stopAgent(input: TaskWorkspaceSessionInput): Promise<void> {
this.assertSession(input.taskId, input.sessionId); this.assertSession(input.taskId, input.sessionId);
const existing = this.pendingTimers.get(input.sessionId); const existing = this.pendingTimers.get(input.sessionId);
if (existing) { if (existing) {
@ -311,14 +303,14 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
}); });
} }
async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void> { async setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
this.updateTask(input.taskId, (currentTask) => ({ this.updateTask(input.taskId, (currentTask) => ({
...currentTask, ...currentTask,
sessions: currentTask.sessions.map((candidate) => (candidate.id === input.sessionId ? { ...candidate, unread: input.unread } : candidate)), sessions: currentTask.sessions.map((candidate) => (candidate.id === input.sessionId ? { ...candidate, unread: input.unread } : candidate)),
})); }));
} }
async renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void> { async renameSession(input: TaskWorkspaceRenameSessionInput): Promise<void> {
const title = input.title.trim(); const title = input.title.trim();
if (!title) { if (!title) {
throw new Error(`Cannot rename session ${input.sessionId} to an empty title`); throw new Error(`Cannot rename session ${input.sessionId} to an empty title`);
@ -329,7 +321,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
})); }));
} }
async closeSession(input: TaskWorkbenchSessionInput): Promise<void> { async closeSession(input: TaskWorkspaceSessionInput): Promise<void> {
this.updateTask(input.taskId, (currentTask) => { this.updateTask(input.taskId, (currentTask) => {
if (currentTask.sessions.length <= 1) { if (currentTask.sessions.length <= 1) {
return currentTask; return currentTask;
@ -342,7 +334,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
}); });
} }
async addSession(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddSessionResponse> { async addSession(input: TaskWorkspaceSelectInput): Promise<TaskWorkspaceAddSessionResponse> {
this.assertTask(input.taskId); this.assertTask(input.taskId);
const nextSessionId = uid(); const nextSessionId = uid();
const nextSession: AgentSession = { const nextSession: AgentSession = {
@ -368,7 +360,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
return { sessionId: nextSession.id }; return { sessionId: nextSession.id };
} }
async changeModel(input: TaskWorkbenchChangeModelInput): Promise<void> { async changeModel(input: TaskWorkspaceChangeModelInput): Promise<void> {
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((entry) => entry.id === input.model)); const group = MODEL_GROUPS.find((candidate) => candidate.models.some((entry) => entry.id === input.model));
if (!group) { if (!group) {
throw new Error(`Unable to resolve model provider for ${input.model}`); throw new Error(`Unable to resolve model provider for ${input.model}`);
@ -382,11 +374,11 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
})); }));
} }
private updateState(updater: (current: TaskWorkbenchSnapshot) => TaskWorkbenchSnapshot): void { private updateState(updater: (current: TaskWorkspaceSnapshot) => TaskWorkspaceSnapshot): void {
const nextSnapshot = updater(this.snapshot); const nextSnapshot = updater(this.snapshot);
this.snapshot = { this.snapshot = {
...nextSnapshot, ...nextSnapshot,
repositories: groupWorkbenchRepositories(nextSnapshot.repos, nextSnapshot.tasks), repositories: groupWorkspaceRepositories(nextSnapshot.repos, nextSnapshot.tasks),
}; };
this.notify(); this.notify();
} }
@ -436,11 +428,11 @@ function candidateEventIndex(task: Task, sessionId: string): number {
return (session?.transcript.length ?? 0) + 1; return (session?.transcript.length ?? 0) + 1;
} }
let sharedMockWorkbenchClient: TaskWorkbenchClient | null = null; let sharedMockWorkspaceClient: TaskWorkspaceClient | null = null;
export function getSharedMockWorkbenchClient(): TaskWorkbenchClient { export function getSharedMockWorkspaceClient(): TaskWorkspaceClient {
if (!sharedMockWorkbenchClient) { if (!sharedMockWorkspaceClient) {
sharedMockWorkbenchClient = new MockWorkbenchStore(); sharedMockWorkspaceClient = new MockWorkspaceStore();
} }
return sharedMockWorkbenchClient; return sharedMockWorkspaceClient;
} }

View file

@ -1,4 +1,4 @@
import type { FoundryAppSnapshot, FoundryBillingPlanId, UpdateFoundryOrganizationProfileInput } from "@sandbox-agent/foundry-shared"; import type { FoundryAppSnapshot, FoundryBillingPlanId, UpdateFoundryOrganizationProfileInput, WorkspaceModelId } from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "../backend-client.js"; import type { BackendClient } from "../backend-client.js";
import type { FoundryAppClient } from "../app-client.js"; import type { FoundryAppClient } from "../app-client.js";
@ -72,6 +72,11 @@ class RemoteFoundryAppStore implements FoundryAppClient {
this.notify(); this.notify();
} }
async setDefaultModel(model: WorkspaceModelId): Promise<void> {
this.snapshot = await this.backend.setAppDefaultModel(model);
this.notify();
}
async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> { async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> {
this.snapshot = await this.backend.updateAppOrganizationProfile(input); this.snapshot = await this.backend.updateAppOrganizationProfile(input);
this.notify(); this.notify();

View file

@ -1,198 +0,0 @@
import type {
TaskWorkbenchAddSessionResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchSessionInput,
TaskWorkbenchUpdateDraftInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "../backend-client.js";
import { groupWorkbenchRepositories } from "../workbench-model.js";
import type { TaskWorkbenchClient } from "../workbench-client.js";
export interface RemoteWorkbenchClientOptions {
backend: BackendClient;
organizationId: string;
}
class RemoteWorkbenchStore implements TaskWorkbenchClient {
private readonly backend: BackendClient;
private readonly organizationId: string;
private snapshot: TaskWorkbenchSnapshot;
private readonly listeners = new Set<() => void>();
private unsubscribeWorkbench: (() => void) | null = null;
private refreshPromise: Promise<void> | null = null;
private refreshRetryTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(options: RemoteWorkbenchClientOptions) {
this.backend = options.backend;
this.organizationId = options.organizationId;
this.snapshot = {
organizationId: options.organizationId,
repos: [],
repositories: [],
tasks: [],
};
}
getSnapshot(): TaskWorkbenchSnapshot {
return this.snapshot;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
this.ensureStarted();
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0 && this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
if (this.listeners.size === 0 && this.unsubscribeWorkbench) {
this.unsubscribeWorkbench();
this.unsubscribeWorkbench = null;
}
};
}
async createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
const created = await this.backend.createWorkbenchTask(this.organizationId, input);
await this.refresh();
return created;
}
async markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.markWorkbenchUnread(this.organizationId, input);
await this.refresh();
}
async renameTask(input: TaskWorkbenchRenameInput): Promise<void> {
await this.backend.renameWorkbenchTask(this.organizationId, input);
await this.refresh();
}
async renameBranch(input: TaskWorkbenchRenameInput): Promise<void> {
await this.backend.renameWorkbenchBranch(this.organizationId, input);
await this.refresh();
}
async archiveTask(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.runAction(this.organizationId, input.taskId, "archive");
await this.refresh();
}
async publishPr(input: TaskWorkbenchSelectInput): Promise<void> {
await this.backend.publishWorkbenchPr(this.organizationId, input);
await this.refresh();
}
async revertFile(input: TaskWorkbenchDiffInput): Promise<void> {
await this.backend.revertWorkbenchFile(this.organizationId, input);
await this.refresh();
}
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
await this.backend.updateWorkbenchDraft(this.organizationId, input);
// Skip refresh — the server broadcast will trigger it, and the frontend
// holds local draft state to avoid the round-trip overwriting user input.
}
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
await this.backend.sendWorkbenchMessage(this.organizationId, input);
await this.refresh();
}
async stopAgent(input: TaskWorkbenchSessionInput): Promise<void> {
await this.backend.stopWorkbenchSession(this.organizationId, input);
await this.refresh();
}
async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
await this.backend.setWorkbenchSessionUnread(this.organizationId, input);
await this.refresh();
}
async renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void> {
await this.backend.renameWorkbenchSession(this.organizationId, input);
await this.refresh();
}
async closeSession(input: TaskWorkbenchSessionInput): Promise<void> {
await this.backend.closeWorkbenchSession(this.organizationId, input);
await this.refresh();
}
async addSession(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddSessionResponse> {
const created = await this.backend.createWorkbenchSession(this.organizationId, input);
await this.refresh();
return created;
}
async changeModel(input: TaskWorkbenchChangeModelInput): Promise<void> {
await this.backend.changeWorkbenchModel(this.organizationId, input);
await this.refresh();
}
private ensureStarted(): void {
if (!this.unsubscribeWorkbench) {
this.unsubscribeWorkbench = this.backend.subscribeWorkbench(this.organizationId, () => {
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
});
}
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}
private scheduleRefreshRetry(): void {
if (this.refreshRetryTimeout || this.listeners.size === 0) {
return;
}
this.refreshRetryTimeout = setTimeout(() => {
this.refreshRetryTimeout = null;
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}, 1_000);
}
private async refresh(): Promise<void> {
if (this.refreshPromise) {
await this.refreshPromise;
return;
}
this.refreshPromise = (async () => {
const nextSnapshot = await this.backend.getWorkbench(this.organizationId);
if (this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
this.snapshot = {
...nextSnapshot,
repositories: nextSnapshot.repositories ?? groupWorkbenchRepositories(nextSnapshot.repos, nextSnapshot.tasks),
};
for (const listener of [...this.listeners]) {
listener();
}
})().finally(() => {
this.refreshPromise = null;
});
await this.refreshPromise;
}
}
export function createRemoteWorkbenchClient(options: RemoteWorkbenchClientOptions): TaskWorkbenchClient {
return new RemoteWorkbenchStore(options);
}

View file

@ -0,0 +1,193 @@
import type {
TaskWorkspaceAddSessionResponse,
TaskWorkspaceChangeModelInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceCreateTaskResponse,
TaskWorkspaceDiffInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSelectInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceSnapshot,
TaskWorkspaceSessionInput,
TaskWorkspaceUpdateDraftInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "../backend-client.js";
import { groupWorkspaceRepositories } from "../workspace-model.js";
import type { TaskWorkspaceClient } from "../workspace-client.js";
export interface RemoteWorkspaceClientOptions {
backend: BackendClient;
organizationId: string;
}
class RemoteWorkspaceStore implements TaskWorkspaceClient {
private readonly backend: BackendClient;
private readonly organizationId: string;
private snapshot: TaskWorkspaceSnapshot;
private readonly listeners = new Set<() => void>();
private unsubscribeWorkspace: (() => void) | null = null;
private refreshPromise: Promise<void> | null = null;
private refreshRetryTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(options: RemoteWorkspaceClientOptions) {
this.backend = options.backend;
this.organizationId = options.organizationId;
this.snapshot = {
organizationId: options.organizationId,
repos: [],
repositories: [],
tasks: [],
};
}
getSnapshot(): TaskWorkspaceSnapshot {
return this.snapshot;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
this.ensureStarted();
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0 && this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
if (this.listeners.size === 0 && this.unsubscribeWorkspace) {
this.unsubscribeWorkspace();
this.unsubscribeWorkspace = null;
}
};
}
async createTask(input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse> {
const created = await this.backend.createWorkspaceTask(this.organizationId, input);
await this.refresh();
return created;
}
async markTaskUnread(input: TaskWorkspaceSelectInput): Promise<void> {
await this.backend.markWorkspaceUnread(this.organizationId, input);
await this.refresh();
}
async renameTask(input: TaskWorkspaceRenameInput): Promise<void> {
await this.backend.renameWorkspaceTask(this.organizationId, input);
await this.refresh();
}
async archiveTask(input: TaskWorkspaceSelectInput): Promise<void> {
await this.backend.runAction(this.organizationId, input.repoId, input.taskId, "archive");
await this.refresh();
}
async publishPr(input: TaskWorkspaceSelectInput): Promise<void> {
await this.backend.publishWorkspacePr(this.organizationId, input);
await this.refresh();
}
async revertFile(input: TaskWorkspaceDiffInput): Promise<void> {
await this.backend.revertWorkspaceFile(this.organizationId, input);
await this.refresh();
}
async updateDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void> {
await this.backend.updateWorkspaceDraft(this.organizationId, input);
// Skip refresh — the server broadcast will trigger it, and the frontend
// holds local draft state to avoid the round-trip overwriting user input.
}
async sendMessage(input: TaskWorkspaceSendMessageInput): Promise<void> {
await this.backend.sendWorkspaceMessage(this.organizationId, input);
await this.refresh();
}
async stopAgent(input: TaskWorkspaceSessionInput): Promise<void> {
await this.backend.stopWorkspaceSession(this.organizationId, input);
await this.refresh();
}
async setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await this.backend.setWorkspaceSessionUnread(this.organizationId, input);
await this.refresh();
}
async renameSession(input: TaskWorkspaceRenameSessionInput): Promise<void> {
await this.backend.renameWorkspaceSession(this.organizationId, input);
await this.refresh();
}
async closeSession(input: TaskWorkspaceSessionInput): Promise<void> {
await this.backend.closeWorkspaceSession(this.organizationId, input);
await this.refresh();
}
async addSession(input: TaskWorkspaceSelectInput): Promise<TaskWorkspaceAddSessionResponse> {
const created = await this.backend.createWorkspaceSession(this.organizationId, input);
await this.refresh();
return created;
}
async changeModel(input: TaskWorkspaceChangeModelInput): Promise<void> {
await this.backend.changeWorkspaceModel(this.organizationId, input);
await this.refresh();
}
private ensureStarted(): void {
if (!this.unsubscribeWorkspace) {
this.unsubscribeWorkspace = this.backend.subscribeWorkspace(this.organizationId, () => {
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
});
}
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}
private scheduleRefreshRetry(): void {
if (this.refreshRetryTimeout || this.listeners.size === 0) {
return;
}
this.refreshRetryTimeout = setTimeout(() => {
this.refreshRetryTimeout = null;
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}, 1_000);
}
private async refresh(): Promise<void> {
if (this.refreshPromise) {
await this.refreshPromise;
return;
}
this.refreshPromise = (async () => {
const nextSnapshot = await this.backend.getWorkspace(this.organizationId);
if (this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
this.snapshot = {
...nextSnapshot,
repositories: nextSnapshot.repositories ?? groupWorkspaceRepositories(nextSnapshot.repos, nextSnapshot.tasks),
};
for (const listener of [...this.listeners]) {
listener();
}
})().finally(() => {
this.refreshPromise = null;
});
await this.refreshPromise;
}
}
export function createRemoteWorkspaceClient(options: RemoteWorkspaceClientOptions): TaskWorkspaceClient {
return new RemoteWorkspaceStore(options);
}

View file

@ -5,8 +5,8 @@ import type {
SandboxProcessesEvent, SandboxProcessesEvent,
SessionEvent, SessionEvent,
TaskEvent, TaskEvent,
WorkbenchSessionDetail, WorkspaceSessionDetail,
WorkbenchTaskDetail, WorkspaceTaskDetail,
OrganizationEvent, OrganizationEvent,
OrganizationSummarySnapshot, OrganizationSummarySnapshot,
} from "@sandbox-agent/foundry-shared"; } from "@sandbox-agent/foundry-shared";
@ -48,16 +48,6 @@ export interface SandboxProcessesTopicParams {
sandboxId: string; sandboxId: string;
} }
function upsertById<T extends { id: string }>(items: T[], nextItem: T, sort: (left: T, right: T) => number): T[] {
const filtered = items.filter((item) => item.id !== nextItem.id);
return [...filtered, nextItem].sort(sort);
}
function upsertByPrId<T extends { prId: string }>(items: T[], nextItem: T, sort: (left: T, right: T) => number): T[] {
const filtered = items.filter((item) => item.prId !== nextItem.prId);
return [...filtered, nextItem].sort(sort);
}
export const topicDefinitions = { export const topicDefinitions = {
app: { app: {
key: () => "app", key: () => "app",
@ -72,41 +62,7 @@ export const topicDefinitions = {
event: "organizationUpdated", event: "organizationUpdated",
connect: (backend: BackendClient, params: OrganizationTopicParams) => backend.connectOrganization(params.organizationId), connect: (backend: BackendClient, params: OrganizationTopicParams) => backend.connectOrganization(params.organizationId),
fetchInitial: (backend: BackendClient, params: OrganizationTopicParams) => backend.getOrganizationSummary(params.organizationId), fetchInitial: (backend: BackendClient, params: OrganizationTopicParams) => backend.getOrganizationSummary(params.organizationId),
applyEvent: (current: OrganizationSummarySnapshot, event: OrganizationEvent) => { applyEvent: (_current: OrganizationSummarySnapshot, event: OrganizationEvent) => event.snapshot,
switch (event.type) {
case "taskSummaryUpdated":
return {
...current,
taskSummaries: upsertById(current.taskSummaries, event.taskSummary, (left, right) => right.updatedAtMs - left.updatedAtMs),
};
case "taskRemoved":
return {
...current,
taskSummaries: current.taskSummaries.filter((task) => task.id !== event.taskId),
};
case "repoAdded":
case "repoUpdated":
return {
...current,
repos: upsertById(current.repos, event.repo, (left, right) => right.latestActivityMs - left.latestActivityMs),
};
case "repoRemoved":
return {
...current,
repos: current.repos.filter((repo) => repo.id !== event.repoId),
};
case "pullRequestUpdated":
return {
...current,
openPullRequests: upsertByPrId(current.openPullRequests, event.pullRequest, (left, right) => right.updatedAtMs - left.updatedAtMs),
};
case "pullRequestRemoved":
return {
...current,
openPullRequests: current.openPullRequests.filter((pullRequest) => pullRequest.prId !== event.prId),
};
}
},
} satisfies TopicDefinition<OrganizationSummarySnapshot, OrganizationTopicParams, OrganizationEvent>, } satisfies TopicDefinition<OrganizationSummarySnapshot, OrganizationTopicParams, OrganizationEvent>,
task: { task: {
@ -114,8 +70,8 @@ export const topicDefinitions = {
event: "taskUpdated", event: "taskUpdated",
connect: (backend: BackendClient, params: TaskTopicParams) => backend.connectTask(params.organizationId, params.repoId, params.taskId), connect: (backend: BackendClient, params: TaskTopicParams) => backend.connectTask(params.organizationId, params.repoId, params.taskId),
fetchInitial: (backend: BackendClient, params: TaskTopicParams) => backend.getTaskDetail(params.organizationId, params.repoId, params.taskId), fetchInitial: (backend: BackendClient, params: TaskTopicParams) => backend.getTaskDetail(params.organizationId, params.repoId, params.taskId),
applyEvent: (_current: WorkbenchTaskDetail, event: TaskEvent) => event.detail, applyEvent: (_current: WorkspaceTaskDetail, event: TaskEvent) => event.detail,
} satisfies TopicDefinition<WorkbenchTaskDetail, TaskTopicParams, TaskEvent>, } satisfies TopicDefinition<WorkspaceTaskDetail, TaskTopicParams, TaskEvent>,
session: { session: {
key: (params: SessionTopicParams) => `session:${params.organizationId}:${params.taskId}:${params.sessionId}`, key: (params: SessionTopicParams) => `session:${params.organizationId}:${params.taskId}:${params.sessionId}`,
@ -123,13 +79,13 @@ export const topicDefinitions = {
connect: (backend: BackendClient, params: SessionTopicParams) => backend.connectTask(params.organizationId, params.repoId, params.taskId), connect: (backend: BackendClient, params: SessionTopicParams) => backend.connectTask(params.organizationId, params.repoId, params.taskId),
fetchInitial: (backend: BackendClient, params: SessionTopicParams) => fetchInitial: (backend: BackendClient, params: SessionTopicParams) =>
backend.getSessionDetail(params.organizationId, params.repoId, params.taskId, params.sessionId), backend.getSessionDetail(params.organizationId, params.repoId, params.taskId, params.sessionId),
applyEvent: (current: WorkbenchSessionDetail, event: SessionEvent) => { applyEvent: (current: WorkspaceSessionDetail, event: SessionEvent) => {
if (event.session.sessionId !== current.sessionId) { if (event.session.sessionId !== current.sessionId) {
return current; return current;
} }
return event.session; return event.session;
}, },
} satisfies TopicDefinition<WorkbenchSessionDetail, SessionTopicParams, SessionEvent>, } satisfies TopicDefinition<WorkspaceSessionDetail, SessionTopicParams, SessionEvent>,
sandboxProcesses: { sandboxProcesses: {
key: (params: SandboxProcessesTopicParams) => `sandbox:${params.organizationId}:${params.sandboxProviderId}:${params.sandboxId}`, key: (params: SandboxProcessesTopicParams) => `sandbox:${params.organizationId}:${params.sandboxProviderId}:${params.sandboxId}`,

View file

@ -1,64 +0,0 @@
import type {
TaskWorkbenchAddSessionResponse,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchCreateTaskResponse,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchSessionInput,
TaskWorkbenchUpdateDraftInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "./backend-client.js";
import { getSharedMockWorkbenchClient } from "./mock/workbench-client.js";
import { createRemoteWorkbenchClient } from "./remote/workbench-client.js";
export type TaskWorkbenchClientMode = "mock" | "remote";
export interface CreateTaskWorkbenchClientOptions {
mode: TaskWorkbenchClientMode;
backend?: BackendClient;
organizationId?: string;
}
export interface TaskWorkbenchClient {
getSnapshot(): TaskWorkbenchSnapshot;
subscribe(listener: () => void): () => void;
createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>;
markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void>;
renameTask(input: TaskWorkbenchRenameInput): Promise<void>;
renameBranch(input: TaskWorkbenchRenameInput): Promise<void>;
archiveTask(input: TaskWorkbenchSelectInput): Promise<void>;
publishPr(input: TaskWorkbenchSelectInput): Promise<void>;
revertFile(input: TaskWorkbenchDiffInput): Promise<void>;
updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void>;
sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void>;
stopAgent(input: TaskWorkbenchSessionInput): Promise<void>;
setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void>;
renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void>;
closeSession(input: TaskWorkbenchSessionInput): Promise<void>;
addSession(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddSessionResponse>;
changeModel(input: TaskWorkbenchChangeModelInput): Promise<void>;
}
export function createTaskWorkbenchClient(options: CreateTaskWorkbenchClientOptions): TaskWorkbenchClient {
if (options.mode === "mock") {
return getSharedMockWorkbenchClient();
}
if (!options.backend) {
throw new Error("Remote task workbench client requires a backend client");
}
if (!options.organizationId) {
throw new Error("Remote task workbench client requires a organization id");
}
return createRemoteWorkbenchClient({
backend: options.backend,
organizationId: options.organizationId,
});
}

View file

@ -0,0 +1,63 @@
import type {
TaskWorkspaceAddSessionResponse,
TaskWorkspaceChangeModelInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceCreateTaskResponse,
TaskWorkspaceDiffInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSelectInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceSnapshot,
TaskWorkspaceSessionInput,
TaskWorkspaceUpdateDraftInput,
} from "@sandbox-agent/foundry-shared";
import type { BackendClient } from "./backend-client.js";
import { getSharedMockWorkspaceClient } from "./mock/workspace-client.js";
import { createRemoteWorkspaceClient } from "./remote/workspace-client.js";
export type TaskWorkspaceClientMode = "mock" | "remote";
export interface CreateTaskWorkspaceClientOptions {
mode: TaskWorkspaceClientMode;
backend?: BackendClient;
organizationId?: string;
}
export interface TaskWorkspaceClient {
getSnapshot(): TaskWorkspaceSnapshot;
subscribe(listener: () => void): () => void;
createTask(input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse>;
markTaskUnread(input: TaskWorkspaceSelectInput): Promise<void>;
renameTask(input: TaskWorkspaceRenameInput): Promise<void>;
archiveTask(input: TaskWorkspaceSelectInput): Promise<void>;
publishPr(input: TaskWorkspaceSelectInput): Promise<void>;
revertFile(input: TaskWorkspaceDiffInput): Promise<void>;
updateDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void>;
sendMessage(input: TaskWorkspaceSendMessageInput): Promise<void>;
stopAgent(input: TaskWorkspaceSessionInput): Promise<void>;
setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
renameSession(input: TaskWorkspaceRenameSessionInput): Promise<void>;
closeSession(input: TaskWorkspaceSessionInput): Promise<void>;
addSession(input: TaskWorkspaceSelectInput): Promise<TaskWorkspaceAddSessionResponse>;
changeModel(input: TaskWorkspaceChangeModelInput): Promise<void>;
}
export function createTaskWorkspaceClient(options: CreateTaskWorkspaceClientOptions): TaskWorkspaceClient {
if (options.mode === "mock") {
return getSharedMockWorkspaceClient();
}
if (!options.backend) {
throw new Error("Remote task workspace client requires a backend client");
}
if (!options.organizationId) {
throw new Error("Remote task workspace client requires a organization id");
}
return createRemoteWorkspaceClient({
backend: options.backend,
organizationId: options.organizationId,
});
}

View file

@ -1,17 +1,17 @@
import type { import type {
WorkbenchAgentKind as AgentKind, WorkspaceAgentKind as AgentKind,
WorkbenchSession as AgentSession, WorkspaceSession as AgentSession,
WorkbenchDiffLineKind as DiffLineKind, WorkspaceDiffLineKind as DiffLineKind,
WorkbenchFileTreeNode as FileTreeNode, WorkspaceFileTreeNode as FileTreeNode,
WorkbenchTask as Task, WorkspaceTask as Task,
TaskWorkbenchSnapshot, TaskWorkspaceSnapshot,
WorkbenchHistoryEvent as HistoryEvent, WorkspaceHistoryEvent as HistoryEvent,
WorkbenchModelGroup as ModelGroup, WorkspaceModelGroup as ModelGroup,
WorkbenchModelId as ModelId, WorkspaceModelId as ModelId,
WorkbenchParsedDiffLine as ParsedDiffLine, WorkspaceParsedDiffLine as ParsedDiffLine,
WorkbenchRepositorySection, WorkspaceRepositorySection,
WorkbenchRepo, WorkspaceRepo,
WorkbenchTranscriptEvent as TranscriptEvent, WorkspaceTranscriptEvent as TranscriptEvent,
} from "@sandbox-agent/foundry-shared"; } from "@sandbox-agent/foundry-shared";
import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" }; import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" };
@ -1300,7 +1300,7 @@ export function buildInitialTasks(): Task[] {
* Uses real public repos so the mock sidebar matches what an actual rivet-dev * Uses real public repos so the mock sidebar matches what an actual rivet-dev
* organization would show after a GitHub sync. * organization would show after a GitHub sync.
*/ */
function buildMockRepos(): WorkbenchRepo[] { function buildMockRepos(): WorkspaceRepo[] {
return rivetDevFixture.repos.map((r) => ({ return rivetDevFixture.repos.map((r) => ({
id: repoIdFromFullName(r.fullName), id: repoIdFromFullName(r.fullName),
label: r.fullName, label: r.fullName,
@ -1349,19 +1349,19 @@ function buildPrTasks(): Task[] {
}); });
} }
export function buildInitialMockLayoutViewModel(): TaskWorkbenchSnapshot { export function buildInitialMockLayoutViewModel(): TaskWorkspaceSnapshot {
const repos = buildMockRepos(); const repos = buildMockRepos();
const tasks = [...buildInitialTasks(), ...buildPrTasks()]; const tasks = [...buildInitialTasks(), ...buildPrTasks()];
return { return {
organizationId: "default", organizationId: "default",
repos, repos,
repositories: groupWorkbenchRepositories(repos, tasks), repositories: groupWorkspaceRepositories(repos, tasks),
tasks, tasks,
}; };
} }
export function groupWorkbenchRepositories(repos: WorkbenchRepo[], tasks: Task[]): WorkbenchRepositorySection[] { export function groupWorkspaceRepositories(repos: WorkspaceRepo[], tasks: Task[]): WorkspaceRepositorySection[] {
const grouped = new Map<string, WorkbenchRepositorySection>(); const grouped = new Map<string, WorkspaceRepositorySection>();
for (const repo of repos) { for (const repo of repos) {
grouped.set(repo.id, { grouped.set(repo.id, {

View file

@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { HistoryEvent, RepoOverview } from "@sandbox-agent/foundry-shared"; import type { AuditLogEvent as HistoryEvent, RepoOverview } from "@sandbox-agent/foundry-shared";
import { createBackendClient } from "../../src/backend-client.js"; import { createBackendClient } from "../../src/backend-client.js";
import { requireImportedRepo } from "./helpers.js"; import { requireImportedRepo } from "./helpers.js";

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { TaskRecord, HistoryEvent } from "@sandbox-agent/foundry-shared"; import type { AuditLogEvent as HistoryEvent, TaskRecord } from "@sandbox-agent/foundry-shared";
import { createBackendClient } from "../../src/backend-client.js"; import { createBackendClient } from "../../src/backend-client.js";
import { requireImportedRepo } from "./helpers.js"; import { requireImportedRepo } from "./helpers.js";

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { TaskWorkbenchSnapshot, WorkbenchSession, WorkbenchTask, WorkbenchModelId, WorkbenchTranscriptEvent } from "@sandbox-agent/foundry-shared"; import type { TaskWorkspaceSnapshot, WorkspaceSession, WorkspaceTask, WorkspaceModelId, WorkspaceTranscriptEvent } from "@sandbox-agent/foundry-shared";
import { createBackendClient } from "../../src/backend-client.js"; import { createBackendClient } from "../../src/backend-client.js";
import { requireImportedRepo } from "./helpers.js"; import { requireImportedRepo } from "./helpers.js";
@ -13,7 +13,7 @@ function requiredEnv(name: string): string {
return value; return value;
} }
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId { function workspaceModelEnv(name: string, fallback: WorkspaceModelId): WorkspaceModelId {
const value = process.env[name]?.trim(); const value = process.env[name]?.trim();
switch (value) { switch (value) {
case "claude-sonnet-4": case "claude-sonnet-4":
@ -50,7 +50,7 @@ async function poll<T>(label: string, timeoutMs: number, intervalMs: number, fn:
} }
} }
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask { function findTask(snapshot: TaskWorkspaceSnapshot, taskId: string): WorkspaceTask {
const task = snapshot.tasks.find((candidate) => candidate.id === taskId); const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task) { if (!task) {
throw new Error(`task ${taskId} missing from snapshot`); throw new Error(`task ${taskId} missing from snapshot`);
@ -58,7 +58,7 @@ function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTas
return task; return task;
} }
function findTab(task: WorkbenchTask, sessionId: string): WorkbenchSession { function findTab(task: WorkspaceTask, sessionId: string): WorkspaceSession {
const tab = task.sessions.find((candidate) => candidate.id === sessionId); const tab = task.sessions.find((candidate) => candidate.id === sessionId);
if (!tab) { if (!tab) {
throw new Error(`tab ${sessionId} missing from task ${task.id}`); throw new Error(`tab ${sessionId} missing from task ${task.id}`);
@ -66,7 +66,7 @@ function findTab(task: WorkbenchTask, sessionId: string): WorkbenchSession {
return tab; return tab;
} }
function extractEventText(event: WorkbenchTranscriptEvent): string { function extractEventText(event: WorkspaceTranscriptEvent): string {
const payload = event.payload; const payload = event.payload;
if (!payload || typeof payload !== "object") { if (!payload || typeof payload !== "object") {
return String(payload ?? ""); return String(payload ?? "");
@ -127,7 +127,7 @@ function extractEventText(event: WorkbenchTranscriptEvent): string {
return JSON.stringify(payload); return JSON.stringify(payload);
} }
function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], expectedText: string): boolean { function transcriptIncludesAgentText(transcript: WorkspaceTranscriptEvent[], expectedText: string): boolean {
return transcript return transcript
.filter((event) => event.sender === "agent") .filter((event) => event.sender === "agent")
.map((event) => extractEventText(event)) .map((event) => extractEventText(event))
@ -135,15 +135,15 @@ function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], exp
.includes(expectedText); .includes(expectedText);
} }
describe("e2e(client): workbench flows", () => { describe("e2e(client): workspace flows", () => {
it.skipIf(!RUN_WORKBENCH_E2E)( it.skipIf(!RUN_WORKBENCH_E2E)(
"creates a task from an imported repo, adds sessions, exchanges messages, and manages workbench state", "creates a task from an imported repo, adds sessions, exchanges messages, and manages workspace state",
{ timeout: 20 * 60_000 }, { timeout: 20 * 60_000 },
async () => { async () => {
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet"; const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet";
const organizationId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; const organizationId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-5.3-codex"); const model = workspaceModelEnv("HF_E2E_MODEL", "gpt-5.3-codex");
const runId = `wb-${Date.now().toString(36)}`; const runId = `wb-${Date.now().toString(36)}`;
const expectedFile = `${runId}.txt`; const expectedFile = `${runId}.txt`;
const expectedInitialReply = `WORKBENCH_READY_${runId}`; const expectedInitialReply = `WORKBENCH_READY_${runId}`;
@ -155,9 +155,9 @@ describe("e2e(client): workbench flows", () => {
}); });
const repo = await requireImportedRepo(client, organizationId, repoRemote); const repo = await requireImportedRepo(client, organizationId, repoRemote);
const created = await client.createWorkbenchTask(organizationId, { const created = await client.createWorkspaceTask(organizationId, {
repoId: repo.repoId, repoId: repo.repoId,
title: `Workbench E2E ${runId}`, title: `Workspace E2E ${runId}`,
branch: `e2e/${runId}`, branch: `e2e/${runId}`,
model, model,
task: `Reply with exactly: ${expectedInitialReply}`, task: `Reply with exactly: ${expectedInitialReply}`,
@ -167,7 +167,7 @@ describe("e2e(client): workbench flows", () => {
"task provisioning", "task provisioning",
12 * 60_000, 12 * 60_000,
2_000, 2_000,
async () => findTask(await client.getWorkbench(organizationId), created.taskId), async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => task.branch === `e2e/${runId}` && task.sessions.length > 0, (task) => task.branch === `e2e/${runId}` && task.sessions.length > 0,
); );
@ -177,7 +177,7 @@ describe("e2e(client): workbench flows", () => {
"initial agent response", "initial agent response",
12 * 60_000, 12 * 60_000,
2_000, 2_000,
async () => findTask(await client.getWorkbench(organizationId), created.taskId), async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => { (task) => {
const tab = findTab(task, primaryTab.id); const tab = findTab(task, primaryTab.id);
return task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedInitialReply); return task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedInitialReply);
@ -187,28 +187,28 @@ describe("e2e(client): workbench flows", () => {
expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy(); expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy();
expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true); expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true);
await client.renameWorkbenchTask(organizationId, { await client.renameWorkspaceTask(organizationId, {
taskId: created.taskId, taskId: created.taskId,
value: `Workbench E2E ${runId} Renamed`, value: `Workspace E2E ${runId} Renamed`,
}); });
await client.renameWorkbenchSession(organizationId, { await client.renameWorkspaceSession(organizationId, {
taskId: created.taskId, taskId: created.taskId,
sessionId: primaryTab.id, sessionId: primaryTab.id,
title: "Primary Session", title: "Primary Session",
}); });
const secondTab = await client.createWorkbenchSession(organizationId, { const secondTab = await client.createWorkspaceSession(organizationId, {
taskId: created.taskId, taskId: created.taskId,
model, model,
}); });
await client.renameWorkbenchSession(organizationId, { await client.renameWorkspaceSession(organizationId, {
taskId: created.taskId, taskId: created.taskId,
sessionId: secondTab.sessionId, sessionId: secondTab.sessionId,
title: "Follow-up Session", title: "Follow-up Session",
}); });
await client.updateWorkbenchDraft(organizationId, { await client.updateWorkspaceDraft(organizationId, {
taskId: created.taskId, taskId: created.taskId,
sessionId: secondTab.sessionId, sessionId: secondTab.sessionId,
text: [ text: [
@ -226,11 +226,11 @@ describe("e2e(client): workbench flows", () => {
], ],
}); });
const drafted = findTask(await client.getWorkbench(organizationId), created.taskId); const drafted = findTask(await client.getWorkspace(organizationId), created.taskId);
expect(findTab(drafted, secondTab.sessionId).draft.text).toContain(expectedReply); expect(findTab(drafted, secondTab.sessionId).draft.text).toContain(expectedReply);
expect(findTab(drafted, secondTab.sessionId).draft.attachments).toHaveLength(1); expect(findTab(drafted, secondTab.sessionId).draft.attachments).toHaveLength(1);
await client.sendWorkbenchMessage(organizationId, { await client.sendWorkspaceMessage(organizationId, {
taskId: created.taskId, taskId: created.taskId,
sessionId: secondTab.sessionId, sessionId: secondTab.sessionId,
text: [ text: [
@ -252,7 +252,7 @@ describe("e2e(client): workbench flows", () => {
"follow-up session response", "follow-up session response",
10 * 60_000, 10 * 60_000,
2_000, 2_000,
async () => findTask(await client.getWorkbench(organizationId), created.taskId), async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => { (task) => {
const tab = findTab(task, secondTab.sessionId); const tab = findTab(task, secondTab.sessionId);
return ( return (
@ -265,17 +265,17 @@ describe("e2e(client): workbench flows", () => {
expect(transcriptIncludesAgentText(secondTranscript, expectedReply)).toBe(true); expect(transcriptIncludesAgentText(secondTranscript, expectedReply)).toBe(true);
expect(withSecondReply.fileChanges.some((file) => file.path === expectedFile)).toBe(true); expect(withSecondReply.fileChanges.some((file) => file.path === expectedFile)).toBe(true);
await client.setWorkbenchSessionUnread(organizationId, { await client.setWorkspaceSessionUnread(organizationId, {
taskId: created.taskId, taskId: created.taskId,
sessionId: secondTab.sessionId, sessionId: secondTab.sessionId,
unread: false, unread: false,
}); });
await client.markWorkbenchUnread(organizationId, { taskId: created.taskId }); await client.markWorkspaceUnread(organizationId, { taskId: created.taskId });
const unreadSnapshot = findTask(await client.getWorkbench(organizationId), created.taskId); const unreadSnapshot = findTask(await client.getWorkspace(organizationId), created.taskId);
expect(unreadSnapshot.sessions.some((tab) => tab.unread)).toBe(true); expect(unreadSnapshot.sessions.some((tab) => tab.unread)).toBe(true);
await client.closeWorkbenchSession(organizationId, { await client.closeWorkspaceSession(organizationId, {
taskId: created.taskId, taskId: created.taskId,
sessionId: secondTab.sessionId, sessionId: secondTab.sessionId,
}); });
@ -284,26 +284,26 @@ describe("e2e(client): workbench flows", () => {
"secondary session closed", "secondary session closed",
30_000, 30_000,
1_000, 1_000,
async () => findTask(await client.getWorkbench(organizationId), created.taskId), async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => !task.sessions.some((tab) => tab.id === secondTab.sessionId), (task) => !task.sessions.some((tab) => tab.id === secondTab.sessionId),
); );
expect(closedSnapshot.sessions).toHaveLength(1); expect(closedSnapshot.sessions).toHaveLength(1);
await client.revertWorkbenchFile(organizationId, { await client.revertWorkspaceFile(organizationId, {
taskId: created.taskId, taskId: created.taskId,
path: expectedFile, path: expectedFile,
}); });
const revertedSnapshot = await poll( const revertedSnapshot = await poll(
"file revert reflected in workbench", "file revert reflected in workspace",
30_000, 30_000,
1_000, 1_000,
async () => findTask(await client.getWorkbench(organizationId), created.taskId), async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => !task.fileChanges.some((file) => file.path === expectedFile), (task) => !task.fileChanges.some((file) => file.path === expectedFile),
); );
expect(revertedSnapshot.fileChanges.some((file) => file.path === expectedFile)).toBe(false); expect(revertedSnapshot.fileChanges.some((file) => file.path === expectedFile)).toBe(false);
expect(revertedSnapshot.title).toBe(`Workbench E2E ${runId} Renamed`); expect(revertedSnapshot.title).toBe(`Workspace E2E ${runId} Renamed`);
expect(findTab(revertedSnapshot, primaryTab.id).sessionName).toBe("Primary Session"); expect(findTab(revertedSnapshot, primaryTab.id).sessionName).toBe("Primary Session");
}, },
); );

View file

@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
createFoundryLogger, createFoundryLogger,
type TaskWorkbenchSnapshot, type TaskWorkspaceSnapshot,
type WorkbenchSession, type WorkspaceSession,
type WorkbenchTask, type WorkspaceTask,
type WorkbenchModelId, type WorkspaceModelId,
type WorkbenchTranscriptEvent, type WorkspaceTranscriptEvent,
} from "@sandbox-agent/foundry-shared"; } from "@sandbox-agent/foundry-shared";
import { createBackendClient } from "../../src/backend-client.js"; import { createBackendClient } from "../../src/backend-client.js";
import { requireImportedRepo } from "./helpers.js"; import { requireImportedRepo } from "./helpers.js";
@ -14,7 +14,7 @@ const RUN_WORKBENCH_LOAD_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E =
const logger = createFoundryLogger({ const logger = createFoundryLogger({
service: "foundry-client-e2e", service: "foundry-client-e2e",
bindings: { bindings: {
suite: "workbench-load", suite: "workspace-load",
}, },
}); });
@ -26,7 +26,7 @@ function requiredEnv(name: string): string {
return value; return value;
} }
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId { function workspaceModelEnv(name: string, fallback: WorkspaceModelId): WorkspaceModelId {
const value = process.env[name]?.trim(); const value = process.env[name]?.trim();
switch (value) { switch (value) {
case "claude-sonnet-4": case "claude-sonnet-4":
@ -72,7 +72,7 @@ async function poll<T>(label: string, timeoutMs: number, intervalMs: number, fn:
} }
} }
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask { function findTask(snapshot: TaskWorkspaceSnapshot, taskId: string): WorkspaceTask {
const task = snapshot.tasks.find((candidate) => candidate.id === taskId); const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task) { if (!task) {
throw new Error(`task ${taskId} missing from snapshot`); throw new Error(`task ${taskId} missing from snapshot`);
@ -80,7 +80,7 @@ function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTas
return task; return task;
} }
function findTab(task: WorkbenchTask, sessionId: string): WorkbenchSession { function findTab(task: WorkspaceTask, sessionId: string): WorkspaceSession {
const tab = task.sessions.find((candidate) => candidate.id === sessionId); const tab = task.sessions.find((candidate) => candidate.id === sessionId);
if (!tab) { if (!tab) {
throw new Error(`tab ${sessionId} missing from task ${task.id}`); throw new Error(`tab ${sessionId} missing from task ${task.id}`);
@ -88,7 +88,7 @@ function findTab(task: WorkbenchTask, sessionId: string): WorkbenchSession {
return tab; return tab;
} }
function extractEventText(event: WorkbenchTranscriptEvent): string { function extractEventText(event: WorkspaceTranscriptEvent): string {
const payload = event.payload; const payload = event.payload;
if (!payload || typeof payload !== "object") { if (!payload || typeof payload !== "object") {
return String(payload ?? ""); return String(payload ?? "");
@ -138,7 +138,7 @@ function extractEventText(event: WorkbenchTranscriptEvent): string {
return typeof envelope.method === "string" ? envelope.method : JSON.stringify(payload); return typeof envelope.method === "string" ? envelope.method : JSON.stringify(payload);
} }
function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], expectedText: string): boolean { function transcriptIncludesAgentText(transcript: WorkspaceTranscriptEvent[], expectedText: string): boolean {
return transcript return transcript
.filter((event) => event.sender === "agent") .filter((event) => event.sender === "agent")
.map((event) => extractEventText(event)) .map((event) => extractEventText(event))
@ -150,7 +150,7 @@ function average(values: number[]): number {
return values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1); return values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1);
} }
async function measureWorkbenchSnapshot( async function measureWorkspaceSnapshot(
client: ReturnType<typeof createBackendClient>, client: ReturnType<typeof createBackendClient>,
organizationId: string, organizationId: string,
iterations: number, iterations: number,
@ -163,11 +163,11 @@ async function measureWorkbenchSnapshot(
transcriptEventCount: number; transcriptEventCount: number;
}> { }> {
const durations: number[] = []; const durations: number[] = [];
let snapshot: TaskWorkbenchSnapshot | null = null; let snapshot: TaskWorkspaceSnapshot | null = null;
for (let index = 0; index < iterations; index += 1) { for (let index = 0; index < iterations; index += 1) {
const startedAt = performance.now(); const startedAt = performance.now();
snapshot = await client.getWorkbench(organizationId); snapshot = await client.getWorkspace(organizationId);
durations.push(performance.now() - startedAt); durations.push(performance.now() - startedAt);
} }
@ -191,12 +191,12 @@ async function measureWorkbenchSnapshot(
}; };
} }
describe("e2e(client): workbench load", () => { describe("e2e(client): workspace load", () => {
it.skipIf(!RUN_WORKBENCH_LOAD_E2E)("runs a simple sequential load profile against the real backend", { timeout: 30 * 60_000 }, async () => { it.skipIf(!RUN_WORKBENCH_LOAD_E2E)("runs a simple sequential load profile against the real backend", { timeout: 30 * 60_000 }, async () => {
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet"; const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet";
const organizationId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; const organizationId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-5.3-codex"); const model = workspaceModelEnv("HF_E2E_MODEL", "gpt-5.3-codex");
const taskCount = intEnv("HF_LOAD_TASK_COUNT", 3); const taskCount = intEnv("HF_LOAD_TASK_COUNT", 3);
const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2); const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2);
const pollIntervalMs = intEnv("HF_LOAD_POLL_INTERVAL_MS", 2_000); const pollIntervalMs = intEnv("HF_LOAD_POLL_INTERVAL_MS", 2_000);
@ -220,16 +220,16 @@ describe("e2e(client): workbench load", () => {
transcriptEventCount: number; transcriptEventCount: number;
}> = []; }> = [];
snapshotSeries.push(await measureWorkbenchSnapshot(client, organizationId, 2)); snapshotSeries.push(await measureWorkspaceSnapshot(client, organizationId, 2));
for (let taskIndex = 0; taskIndex < taskCount; taskIndex += 1) { for (let taskIndex = 0; taskIndex < taskCount; taskIndex += 1) {
const runId = `load-${taskIndex}-${Date.now().toString(36)}`; const runId = `load-${taskIndex}-${Date.now().toString(36)}`;
const initialReply = `LOAD_INIT_${runId}`; const initialReply = `LOAD_INIT_${runId}`;
const createStartedAt = performance.now(); const createStartedAt = performance.now();
const created = await client.createWorkbenchTask(organizationId, { const created = await client.createWorkspaceTask(organizationId, {
repoId: repo.repoId, repoId: repo.repoId,
title: `Workbench Load ${runId}`, title: `Workspace Load ${runId}`,
branch: `load/${runId}`, branch: `load/${runId}`,
model, model,
task: `Reply with exactly: ${initialReply}`, task: `Reply with exactly: ${initialReply}`,
@ -241,7 +241,7 @@ describe("e2e(client): workbench load", () => {
`task ${runId} provisioning`, `task ${runId} provisioning`,
12 * 60_000, 12 * 60_000,
pollIntervalMs, pollIntervalMs,
async () => findTask(await client.getWorkbench(organizationId), created.taskId), async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => { (task) => {
const tab = task.sessions[0]; const tab = task.sessions[0];
return Boolean(tab && task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, initialReply)); return Boolean(tab && task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, initialReply));
@ -256,13 +256,13 @@ describe("e2e(client): workbench load", () => {
for (let sessionIndex = 0; sessionIndex < extraSessionCount; sessionIndex += 1) { for (let sessionIndex = 0; sessionIndex < extraSessionCount; sessionIndex += 1) {
const expectedReply = `LOAD_REPLY_${runId}_${sessionIndex}`; const expectedReply = `LOAD_REPLY_${runId}_${sessionIndex}`;
const createSessionStartedAt = performance.now(); const createSessionStartedAt = performance.now();
const createdSession = await client.createWorkbenchSession(organizationId, { const createdSession = await client.createWorkspaceSession(organizationId, {
taskId: created.taskId, taskId: created.taskId,
model, model,
}); });
createSessionLatencies.push(performance.now() - createSessionStartedAt); createSessionLatencies.push(performance.now() - createSessionStartedAt);
await client.sendWorkbenchMessage(organizationId, { await client.sendWorkspaceMessage(organizationId, {
taskId: created.taskId, taskId: created.taskId,
sessionId: createdSession.sessionId, sessionId: createdSession.sessionId,
text: `Run pwd in the repo, then reply with exactly: ${expectedReply}`, text: `Run pwd in the repo, then reply with exactly: ${expectedReply}`,
@ -274,7 +274,7 @@ describe("e2e(client): workbench load", () => {
`task ${runId} session ${sessionIndex} reply`, `task ${runId} session ${sessionIndex} reply`,
10 * 60_000, 10 * 60_000,
pollIntervalMs, pollIntervalMs,
async () => findTask(await client.getWorkbench(organizationId), created.taskId), async () => findTask(await client.getWorkspace(organizationId), created.taskId),
(task) => { (task) => {
const tab = findTab(task, createdSession.sessionId); const tab = findTab(task, createdSession.sessionId);
return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply); return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply);
@ -285,14 +285,14 @@ describe("e2e(client): workbench load", () => {
expect(transcriptIncludesAgentText(findTab(withReply, createdSession.sessionId).transcript, expectedReply)).toBe(true); expect(transcriptIncludesAgentText(findTab(withReply, createdSession.sessionId).transcript, expectedReply)).toBe(true);
} }
const snapshotMetrics = await measureWorkbenchSnapshot(client, organizationId, 3); const snapshotMetrics = await measureWorkspaceSnapshot(client, organizationId, 3);
snapshotSeries.push(snapshotMetrics); snapshotSeries.push(snapshotMetrics);
logger.info( logger.info(
{ {
taskIndex: taskIndex + 1, taskIndex: taskIndex + 1,
...snapshotMetrics, ...snapshotMetrics,
}, },
"workbench_load_snapshot", "workspace_load_snapshot",
); );
} }
@ -314,7 +314,7 @@ describe("e2e(client): workbench load", () => {
snapshotTranscriptFinalCount: lastSnapshot.transcriptEventCount, snapshotTranscriptFinalCount: lastSnapshot.transcriptEventCount,
}; };
logger.info(summary, "workbench_load_summary"); logger.info(summary, "workspace_load_summary");
expect(createTaskLatencies.length).toBe(taskCount); expect(createTaskLatencies.length).toBe(taskCount);
expect(provisionLatencies.length).toBe(taskCount); expect(provisionLatencies.length).toBe(taskCount);

View file

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

View file

@ -115,8 +115,12 @@ describe("RemoteSubscriptionManager", () => {
]); ]);
conn.emit("organizationUpdated", { conn.emit("organizationUpdated", {
type: "taskSummaryUpdated", type: "organizationUpdated",
taskSummary: { snapshot: {
organizationId: "org-1",
repos: [],
taskSummaries: [
{
id: "task-1", id: "task-1",
repoId: "repo-1", repoId: "repo-1",
title: "Updated task", title: "Updated task",
@ -127,6 +131,9 @@ describe("RemoteSubscriptionManager", () => {
pullRequest: null, pullRequest: null,
sessionsSummary: [], sessionsSummary: [],
}, },
],
openPullRequests: [],
},
} satisfies OrganizationEvent); } satisfies OrganizationEvent);
expect(manager.getSnapshot("organization", params)?.taskSummaries[0]?.title).toBe("Updated task"); expect(manager.getSnapshot("organization", params)?.taskSummaries[0]?.title).toBe("Updated task");

View file

@ -7,10 +7,10 @@ import type {
FoundryAppSnapshot, FoundryAppSnapshot,
FoundryOrganization, FoundryOrganization,
TaskStatus, TaskStatus,
TaskWorkbenchSnapshot, TaskWorkspaceSnapshot,
WorkbenchSandboxSummary, WorkspaceSandboxSummary,
WorkbenchSessionSummary, WorkspaceSessionSummary,
WorkbenchTaskStatus, WorkspaceTaskStatus,
} from "@sandbox-agent/foundry-shared"; } from "@sandbox-agent/foundry-shared";
import { useSubscription } from "@sandbox-agent/foundry-client"; import { useSubscription } from "@sandbox-agent/foundry-client";
import type { DebugSubscriptionTopic } from "@sandbox-agent/foundry-client"; import type { DebugSubscriptionTopic } from "@sandbox-agent/foundry-client";
@ -18,7 +18,7 @@ import { describeTaskState } from "../features/tasks/status";
interface DevPanelProps { interface DevPanelProps {
organizationId: string; organizationId: string;
snapshot: TaskWorkbenchSnapshot; snapshot: TaskWorkspaceSnapshot;
organization?: FoundryOrganization | null; organization?: FoundryOrganization | null;
focusedTask?: DevPanelFocusedTask | null; focusedTask?: DevPanelFocusedTask | null;
} }
@ -27,14 +27,14 @@ export interface DevPanelFocusedTask {
id: string; id: string;
repoId: string; repoId: string;
title: string | null; title: string | null;
status: WorkbenchTaskStatus; status: WorkspaceTaskStatus;
runtimeStatus?: TaskStatus | null; runtimeStatus?: TaskStatus | null;
statusMessage?: string | null; statusMessage?: string | null;
branch?: string | null; branch?: string | null;
activeSandboxId?: string | null; activeSandboxId?: string | null;
activeSessionId?: string | null; activeSessionId?: string | null;
sandboxes?: WorkbenchSandboxSummary[]; sandboxes?: WorkspaceSandboxSummary[];
sessions?: WorkbenchSessionSummary[]; sessions?: WorkspaceSessionSummary[];
} }
interface TopicInfo { interface TopicInfo {

View file

@ -4,11 +4,11 @@ import { useStyletron } from "baseui";
import { import {
createErrorContext, createErrorContext,
type FoundryOrganization, type FoundryOrganization,
type TaskWorkbenchSnapshot, type TaskWorkspaceSnapshot,
type WorkbenchOpenPrSummary, type WorkspaceOpenPrSummary,
type WorkbenchSessionSummary, type WorkspaceSessionSummary,
type WorkbenchTaskDetail, type WorkspaceTaskDetail,
type WorkbenchTaskSummary, type WorkspaceTaskSummary,
} from "@sandbox-agent/foundry-shared"; } from "@sandbox-agent/foundry-shared";
import { useSubscription } from "@sandbox-agent/foundry-client"; import { useSubscription } from "@sandbox-agent/foundry-client";
@ -39,7 +39,7 @@ import {
type Message, type Message,
type ModelId, type ModelId,
} from "./mock-layout/view-model"; } from "./mock-layout/view-model";
import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app"; import { activeMockOrganization, activeMockUser, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
import { backendClient } from "../lib/backend"; import { backendClient } from "../lib/backend";
import { subscriptionManager } from "../lib/subscription"; import { subscriptionManager } from "../lib/subscription";
import { describeTaskState, isProvisioningTaskStatus } from "../features/tasks/status"; import { describeTaskState, isProvisioningTaskStatus } from "../features/tasks/status";
@ -131,7 +131,7 @@ function GithubInstallationWarning({
} }
function toSessionModel( function toSessionModel(
summary: WorkbenchSessionSummary, summary: WorkspaceSessionSummary,
sessionDetail?: { draft: Task["sessions"][number]["draft"]; transcript: Task["sessions"][number]["transcript"] }, sessionDetail?: { draft: Task["sessions"][number]["draft"]; transcript: Task["sessions"][number]["transcript"] },
): Task["sessions"][number] { ): Task["sessions"][number] {
return { return {
@ -155,8 +155,8 @@ function toSessionModel(
} }
function toTaskModel( function toTaskModel(
summary: WorkbenchTaskSummary, summary: WorkspaceTaskSummary,
detail?: WorkbenchTaskDetail, detail?: WorkspaceTaskDetail,
sessionCache?: Map<string, { draft: Task["sessions"][number]["draft"]; transcript: Task["sessions"][number]["transcript"] }>, sessionCache?: Map<string, { draft: Task["sessions"][number]["draft"]; transcript: Task["sessions"][number]["transcript"] }>,
): Task { ): Task {
const sessions = detail?.sessionsSummary ?? summary.sessionsSummary; const sessions = detail?.sessionsSummary ?? summary.sessionsSummary;
@ -190,7 +190,7 @@ function isOpenPrTaskId(taskId: string): boolean {
return taskId.startsWith(OPEN_PR_TASK_PREFIX); return taskId.startsWith(OPEN_PR_TASK_PREFIX);
} }
function toOpenPrTaskModel(pullRequest: WorkbenchOpenPrSummary): Task { function toOpenPrTaskModel(pullRequest: WorkspaceOpenPrSummary): Task {
return { return {
id: openPrTaskId(pullRequest.prId), id: openPrTaskId(pullRequest.prId),
repoId: pullRequest.repoId, repoId: pullRequest.repoId,
@ -241,7 +241,7 @@ function groupRepositories(repos: Array<{ id: string; label: string }>, tasks: T
.filter((repo) => repo.tasks.length > 0); .filter((repo) => repo.tasks.length > 0);
} }
interface WorkbenchActions { interface WorkspaceActions {
createTask(input: { createTask(input: {
repoId: string; repoId: string;
task: string; task: string;
@ -252,7 +252,6 @@ interface WorkbenchActions {
}): Promise<{ taskId: string; sessionId?: string }>; }): Promise<{ taskId: string; sessionId?: string }>;
markTaskUnread(input: { taskId: string }): Promise<void>; markTaskUnread(input: { taskId: string }): Promise<void>;
renameTask(input: { taskId: string; value: string }): Promise<void>; renameTask(input: { taskId: string; value: string }): Promise<void>;
renameBranch(input: { taskId: string; value: string }): Promise<void>;
archiveTask(input: { taskId: string }): Promise<void>; archiveTask(input: { taskId: string }): Promise<void>;
publishPr(input: { taskId: string }): Promise<void>; publishPr(input: { taskId: string }): Promise<void>;
revertFile(input: { taskId: string; path: string }): Promise<void>; revertFile(input: { taskId: string; path: string }): Promise<void>;
@ -264,14 +263,14 @@ interface WorkbenchActions {
closeSession(input: { taskId: string; sessionId: string }): Promise<void>; closeSession(input: { taskId: string; sessionId: string }): Promise<void>;
addSession(input: { taskId: string; model?: string }): Promise<{ sessionId: string }>; addSession(input: { taskId: string; model?: string }): Promise<{ sessionId: string }>;
changeModel(input: { taskId: string; sessionId: string; model: ModelId }): Promise<void>; changeModel(input: { taskId: string; sessionId: string; model: ModelId }): Promise<void>;
reloadGithubOrganization(): Promise<void>; adminReloadGithubOrganization(): Promise<void>;
reloadGithubPullRequests(): Promise<void>; adminReloadGithubPullRequests(): Promise<void>;
reloadGithubRepository(repoId: string): Promise<void>; adminReloadGithubRepository(repoId: string): Promise<void>;
reloadGithubPullRequest(repoId: string, prNumber: number): Promise<void>; adminReloadGithubPullRequest(repoId: string, prNumber: number): Promise<void>;
} }
const TranscriptPanel = memo(function TranscriptPanel({ const TranscriptPanel = memo(function TranscriptPanel({
taskWorkbenchClient, taskWorkspaceClient,
task, task,
hasSandbox, hasSandbox,
activeSessionId, activeSessionId,
@ -290,7 +289,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
selectedSessionHydrating = false, selectedSessionHydrating = false,
onNavigateToUsage, onNavigateToUsage,
}: { }: {
taskWorkbenchClient: WorkbenchActions; taskWorkspaceClient: WorkspaceActions;
task: Task; task: Task;
hasSandbox: boolean; hasSandbox: boolean;
activeSessionId: string | null; activeSessionId: string | null;
@ -310,8 +309,11 @@ const TranscriptPanel = memo(function TranscriptPanel({
onNavigateToUsage?: () => void; onNavigateToUsage?: () => void;
}) { }) {
const t = useFoundryTokens(); const t = useFoundryTokens();
const [defaultModel, setDefaultModel] = useState<ModelId>("claude-sonnet-4"); const appSnapshot = useMockAppSnapshot();
const [editingField, setEditingField] = useState<"title" | "branch" | null>(null); const appClient = useMockAppClient();
const currentUser = activeMockUser(appSnapshot);
const defaultModel = currentUser?.defaultModel ?? "claude-sonnet-4";
const [editingField, setEditingField] = useState<"title" | null>(null);
const [editValue, setEditValue] = useState(""); const [editValue, setEditValue] = useState("");
const [editingSessionId, setEditingSessionId] = useState<string | null>(null); const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingSessionName, setEditingSessionName] = useState(""); const [editingSessionName, setEditingSessionName] = useState("");
@ -436,14 +438,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
return; return;
} }
void taskWorkbenchClient.setSessionUnread({ void taskWorkspaceClient.setSessionUnread({
taskId: task.id, taskId: task.id,
sessionId: activeAgentSession.id, sessionId: activeAgentSession.id,
unread: false, unread: false,
}); });
}, [activeAgentSession?.id, activeAgentSession?.unread, task.id]); }, [activeAgentSession?.id, activeAgentSession?.unread, task.id]);
const startEditingField = useCallback((field: "title" | "branch", value: string) => { const startEditingField = useCallback((field: "title", value: string) => {
setEditingField(field); setEditingField(field);
setEditValue(value); setEditValue(value);
}, []); }, []);
@ -453,18 +455,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
}, []); }, []);
const commitEditingField = useCallback( const commitEditingField = useCallback(
(field: "title" | "branch") => { (field: "title") => {
const value = editValue.trim(); const value = editValue.trim();
if (!value) { if (!value) {
setEditingField(null); setEditingField(null);
return; return;
} }
if (field === "title") { void taskWorkspaceClient.renameTask({ taskId: task.id, value });
void taskWorkbenchClient.renameTask({ taskId: task.id, value });
} else {
void taskWorkbenchClient.renameBranch({ taskId: task.id, value });
}
setEditingField(null); setEditingField(null);
}, },
[editValue, task.id], [editValue, task.id],
@ -474,7 +472,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
const flushDraft = useCallback( const flushDraft = useCallback(
(text: string, nextAttachments: LineAttachment[], sessionId: string) => { (text: string, nextAttachments: LineAttachment[], sessionId: string) => {
void taskWorkbenchClient.updateDraft({ void taskWorkspaceClient.updateDraft({
taskId: task.id, taskId: task.id,
sessionId, sessionId,
text, text,
@ -535,7 +533,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSetActiveSessionId(promptSession.id); onSetActiveSessionId(promptSession.id);
onSetLastAgentSessionId(promptSession.id); onSetLastAgentSessionId(promptSession.id);
void taskWorkbenchClient.sendMessage({ void taskWorkspaceClient.sendMessage({
taskId: task.id, taskId: task.id,
sessionId: promptSession.id, sessionId: promptSession.id,
text, text,
@ -548,7 +546,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
return; return;
} }
void taskWorkbenchClient.stopAgent({ void taskWorkspaceClient.stopAgent({
taskId: task.id, taskId: task.id,
sessionId: promptSession.id, sessionId: promptSession.id,
}); });
@ -562,7 +560,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSetLastAgentSessionId(sessionId); onSetLastAgentSessionId(sessionId);
const session = task.sessions.find((candidate) => candidate.id === sessionId); const session = task.sessions.find((candidate) => candidate.id === sessionId);
if (session?.unread) { if (session?.unread) {
void taskWorkbenchClient.setSessionUnread({ void taskWorkspaceClient.setSessionUnread({
taskId: task.id, taskId: task.id,
sessionId, sessionId,
unread: false, unread: false,
@ -576,7 +574,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
const setSessionUnread = useCallback( const setSessionUnread = useCallback(
(sessionId: string, unread: boolean) => { (sessionId: string, unread: boolean) => {
void taskWorkbenchClient.setSessionUnread({ taskId: task.id, sessionId, unread }); void taskWorkspaceClient.setSessionUnread({ taskId: task.id, sessionId, unread });
}, },
[task.id], [task.id],
); );
@ -610,7 +608,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
return; return;
} }
void taskWorkbenchClient.renameSession({ void taskWorkspaceClient.renameSession({
taskId: task.id, taskId: task.id,
sessionId: editingSessionId, sessionId: editingSessionId,
title: trimmedName, title: trimmedName,
@ -631,7 +629,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
} }
onSyncRouteSession(task.id, nextSessionId); onSyncRouteSession(task.id, nextSessionId);
void taskWorkbenchClient.closeSession({ taskId: task.id, sessionId }); void taskWorkspaceClient.closeSession({ taskId: task.id, sessionId });
}, },
[activeSessionId, task.id, task.sessions, lastAgentSessionId, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession], [activeSessionId, task.id, task.sessions, lastAgentSessionId, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession],
); );
@ -651,7 +649,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
const addSession = useCallback(() => { const addSession = useCallback(() => {
void (async () => { void (async () => {
const { sessionId } = await taskWorkbenchClient.addSession({ taskId: task.id }); const { sessionId } = await taskWorkspaceClient.addSession({ taskId: task.id });
onSetLastAgentSessionId(sessionId); onSetLastAgentSessionId(sessionId);
onSetActiveSessionId(sessionId); onSetActiveSessionId(sessionId);
onSyncRouteSession(task.id, sessionId); onSyncRouteSession(task.id, sessionId);
@ -664,7 +662,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
throw new Error(`Unable to change model for task ${task.id} without an active prompt session`); throw new Error(`Unable to change model for task ${task.id} without an active prompt session`);
} }
void taskWorkbenchClient.changeModel({ void taskWorkspaceClient.changeModel({
taskId: task.id, taskId: task.id,
sessionId: promptSession.id, sessionId: promptSession.id,
model, model,
@ -939,7 +937,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
messageRefs={messageRefs} messageRefs={messageRefs}
historyEvents={historyEvents} historyEvents={historyEvents}
onSelectHistoryEvent={jumpToHistoryEvent} onSelectHistoryEvent={jumpToHistoryEvent}
targetMessageId={pendingHistoryTarget && activeSessionId === pendingHistoryTarget.sessionId ? pendingHistoryTarget.messageId : null} targetMessageId={pendingHistoryTarget && activeAgentSession?.id === pendingHistoryTarget.sessionId ? pendingHistoryTarget.messageId : null}
onTargetMessageResolved={() => setPendingHistoryTarget(null)} onTargetMessageResolved={() => setPendingHistoryTarget(null)}
copiedMessageId={copiedMessageId} copiedMessageId={copiedMessageId}
onCopyMessage={(message) => { onCopyMessage={(message) => {
@ -966,7 +964,9 @@ const TranscriptPanel = memo(function TranscriptPanel({
onStop={stopAgent} onStop={stopAgent}
onRemoveAttachment={removeAttachment} onRemoveAttachment={removeAttachment}
onChangeModel={changeModel} onChangeModel={changeModel}
onSetDefaultModel={setDefaultModel} onSetDefaultModel={(model) => {
void appClient.setDefaultModel(model);
}}
/> />
) : null} ) : null}
</div> </div>
@ -1280,27 +1280,26 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const [css] = useStyletron(); const [css] = useStyletron();
const t = useFoundryTokens(); const t = useFoundryTokens();
const navigate = useNavigate(); const navigate = useNavigate();
const taskWorkbenchClient = useMemo<WorkbenchActions>( const taskWorkspaceClient = useMemo<WorkspaceActions>(
() => ({ () => ({
createTask: (input) => backendClient.createWorkbenchTask(organizationId, input), createTask: (input) => backendClient.createWorkspaceTask(organizationId, input),
markTaskUnread: (input) => backendClient.markWorkbenchUnread(organizationId, input), markTaskUnread: (input) => backendClient.markWorkspaceUnread(organizationId, input),
renameTask: (input) => backendClient.renameWorkbenchTask(organizationId, input), renameTask: (input) => backendClient.renameWorkspaceTask(organizationId, input),
renameBranch: (input) => backendClient.renameWorkbenchBranch(organizationId, input), archiveTask: async (input) => backendClient.runAction(organizationId, input.repoId, input.taskId, "archive"),
archiveTask: async (input) => backendClient.runAction(organizationId, input.taskId, "archive"), publishPr: (input) => backendClient.publishWorkspacePr(organizationId, input),
publishPr: (input) => backendClient.publishWorkbenchPr(organizationId, input), revertFile: (input) => backendClient.revertWorkspaceFile(organizationId, input),
revertFile: (input) => backendClient.revertWorkbenchFile(organizationId, input), updateDraft: (input) => backendClient.updateWorkspaceDraft(organizationId, input),
updateDraft: (input) => backendClient.updateWorkbenchDraft(organizationId, input), sendMessage: (input) => backendClient.sendWorkspaceMessage(organizationId, input),
sendMessage: (input) => backendClient.sendWorkbenchMessage(organizationId, input), stopAgent: (input) => backendClient.stopWorkspaceSession(organizationId, input),
stopAgent: (input) => backendClient.stopWorkbenchSession(organizationId, input), setSessionUnread: (input) => backendClient.setWorkspaceSessionUnread(organizationId, input),
setSessionUnread: (input) => backendClient.setWorkbenchSessionUnread(organizationId, input), renameSession: (input) => backendClient.renameWorkspaceSession(organizationId, input),
renameSession: (input) => backendClient.renameWorkbenchSession(organizationId, input), closeSession: (input) => backendClient.closeWorkspaceSession(organizationId, input),
closeSession: (input) => backendClient.closeWorkbenchSession(organizationId, input), addSession: (input) => backendClient.createWorkspaceSession(organizationId, input),
addSession: (input) => backendClient.createWorkbenchSession(organizationId, input), changeModel: (input) => backendClient.changeWorkspaceModel(organizationId, input),
changeModel: (input) => backendClient.changeWorkbenchModel(organizationId, input), adminReloadGithubOrganization: () => backendClient.adminReloadGithubOrganization(organizationId),
reloadGithubOrganization: () => backendClient.reloadGithubOrganization(organizationId), adminReloadGithubPullRequests: () => backendClient.adminReloadGithubPullRequests(organizationId),
reloadGithubPullRequests: () => backendClient.reloadGithubPullRequests(organizationId), adminReloadGithubRepository: (repoId) => backendClient.adminReloadGithubRepository(organizationId, repoId),
reloadGithubRepository: (repoId) => backendClient.reloadGithubRepository(organizationId, repoId), adminReloadGithubPullRequest: (repoId, prNumber) => backendClient.adminReloadGithubPullRequest(organizationId, repoId, prNumber),
reloadGithubPullRequest: (repoId, prNumber) => backendClient.reloadGithubPullRequest(organizationId, repoId, prNumber),
}), }),
[organizationId], [organizationId],
); );
@ -1495,7 +1494,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
}, [selectedOpenPullRequest, selectedTaskId, tasks]); }, [selectedOpenPullRequest, selectedTaskId, tasks]);
const materializeOpenPullRequest = useCallback( const materializeOpenPullRequest = useCallback(
async (pullRequest: WorkbenchOpenPrSummary) => { async (pullRequest: WorkspaceOpenPrSummary) => {
if (resolvingOpenPullRequestsRef.current.has(pullRequest.prId)) { if (resolvingOpenPullRequestsRef.current.has(pullRequest.prId)) {
return; return;
} }
@ -1504,7 +1503,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
setMaterializingOpenPrId(pullRequest.prId); setMaterializingOpenPrId(pullRequest.prId);
try { try {
const { taskId, sessionId } = await taskWorkbenchClient.createTask({ const { taskId, sessionId } = await taskWorkspaceClient.createTask({
repoId: pullRequest.repoId, repoId: pullRequest.repoId,
task: `Continue work on GitHub PR #${pullRequest.number}: ${pullRequest.title}`, task: `Continue work on GitHub PR #${pullRequest.number}: ${pullRequest.title}`,
model: "gpt-5.3-codex", model: "gpt-5.3-codex",
@ -1534,7 +1533,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
); );
} }
}, },
[navigate, taskWorkbenchClient, organizationId], [navigate, taskWorkspaceClient, organizationId],
); );
useEffect(() => { useEffect(() => {
@ -1664,7 +1663,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
autoCreatingSessionForTaskRef.current.add(activeTask.id); autoCreatingSessionForTaskRef.current.add(activeTask.id);
void (async () => { void (async () => {
try { try {
const { sessionId } = await taskWorkbenchClient.addSession({ taskId: activeTask.id }); const { sessionId } = await taskWorkspaceClient.addSession({ taskId: activeTask.id });
syncRouteSession(activeTask.id, sessionId, true); syncRouteSession(activeTask.id, sessionId, true);
} catch (error) { } catch (error) {
logger.error( logger.error(
@ -1672,13 +1671,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskId: activeTask.id, taskId: activeTask.id,
...createErrorContext(error), ...createErrorContext(error),
}, },
"failed_to_auto_create_workbench_session", "failed_to_auto_create_workspace_session",
); );
// Keep the guard in the set on error to prevent retry storms. // Keep the guard in the set on error to prevent retry storms.
// The guard is cleared when sessions appear (line above) or the task changes. // The guard is cleared when sessions appear (line above) or the task changes.
} }
})(); })();
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]); }, [activeTask, selectedSessionId, syncRouteSession, taskWorkspaceClient]);
const createTask = useCallback( const createTask = useCallback(
(overrideRepoId?: string, options?: { title?: string; task?: string; branch?: string; onBranch?: string }) => { (overrideRepoId?: string, options?: { title?: string; task?: string; branch?: string; onBranch?: string }) => {
@ -1688,7 +1687,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
throw new Error("Cannot create a task without an available repo"); throw new Error("Cannot create a task without an available repo");
} }
const { taskId, sessionId } = await taskWorkbenchClient.createTask({ const { taskId, sessionId } = await taskWorkspaceClient.createTask({
repoId, repoId,
task: options?.task ?? "New task", task: options?.task ?? "New task",
model: "gpt-5.3-codex", model: "gpt-5.3-codex",
@ -1706,7 +1705,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
}); });
})(); })();
}, },
[navigate, selectedNewTaskRepoId, taskWorkbenchClient, organizationId], [navigate, selectedNewTaskRepoId, taskWorkspaceClient, organizationId],
); );
const openDiffTab = useCallback( const openDiffTab = useCallback(
@ -1757,7 +1756,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
); );
const markTaskUnread = useCallback((id: string) => { const markTaskUnread = useCallback((id: string) => {
void taskWorkbenchClient.markTaskUnread({ taskId: id }); void taskWorkspaceClient.markTaskUnread({ taskId: id });
}, []); }, []);
const renameTask = useCallback( const renameTask = useCallback(
@ -1777,29 +1776,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
return; return;
} }
void taskWorkbenchClient.renameTask({ taskId: id, value: trimmedTitle }); void taskWorkspaceClient.renameTask({ taskId: id, value: trimmedTitle });
},
[tasks],
);
const renameBranch = useCallback(
(id: string) => {
const currentTask = tasks.find((task) => task.id === id);
if (!currentTask) {
throw new Error(`Unable to rename missing task ${id}`);
}
const nextBranch = window.prompt("Rename branch", currentTask.branch ?? "");
if (nextBranch === null) {
return;
}
const trimmedBranch = nextBranch.trim();
if (!trimmedBranch) {
return;
}
void taskWorkbenchClient.renameBranch({ taskId: id, value: trimmedBranch });
}, },
[tasks], [tasks],
); );
@ -1808,14 +1785,14 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
if (!activeTask) { if (!activeTask) {
throw new Error("Cannot archive without an active task"); throw new Error("Cannot archive without an active task");
} }
void taskWorkbenchClient.archiveTask({ taskId: activeTask.id }); void taskWorkspaceClient.archiveTask({ taskId: activeTask.id });
}, [activeTask]); }, [activeTask]);
const publishPr = useCallback(() => { const publishPr = useCallback(() => {
if (!activeTask) { if (!activeTask) {
throw new Error("Cannot publish PR without an active task"); throw new Error("Cannot publish PR without an active task");
} }
void taskWorkbenchClient.publishPr({ taskId: activeTask.id }); void taskWorkspaceClient.publishPr({ taskId: activeTask.id });
}, [activeTask]); }, [activeTask]);
const revertFile = useCallback( const revertFile = useCallback(
@ -1835,7 +1812,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
: (current[activeTask.id] ?? null), : (current[activeTask.id] ?? null),
})); }));
void taskWorkbenchClient.revertFile({ void taskWorkspaceClient.revertFile({
taskId: activeTask.id, taskId: activeTask.id,
path, path,
}); });
@ -1939,14 +1916,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
onSelectNewTaskRepo={setSelectedNewTaskRepoId} onSelectNewTaskRepo={setSelectedNewTaskRepoId}
onMarkUnread={markTaskUnread} onMarkUnread={markTaskUnread}
onRenameTask={renameTask} onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderRepositories={reorderRepositories} onReorderRepositories={reorderRepositories}
taskOrderByRepository={taskOrderByRepository} taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks} onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()} onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()} onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)} onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)} onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => setLeftSidebarOpen(false)} onToggleSidebar={() => setLeftSidebarOpen(false)}
/> />
</div> </div>
@ -2079,7 +2055,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
{showDevPanel && ( {showDevPanel && (
<DevPanel <DevPanel
organizationId={organizationId} organizationId={organizationId}
snapshot={{ organizationId, repos: organizationRepos, repositories: rawRepositories, tasks } as TaskWorkbenchSnapshot} snapshot={{ organizationId, repos: organizationRepos, repositories: rawRepositories, tasks } as TaskWorkspaceSnapshot}
organization={activeOrg} organization={activeOrg}
focusedTask={null} focusedTask={null}
/> />
@ -2114,14 +2090,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
onSelectNewTaskRepo={setSelectedNewTaskRepoId} onSelectNewTaskRepo={setSelectedNewTaskRepoId}
onMarkUnread={markTaskUnread} onMarkUnread={markTaskUnread}
onRenameTask={renameTask} onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderRepositories={reorderRepositories} onReorderRepositories={reorderRepositories}
taskOrderByRepository={taskOrderByRepository} taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks} onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()} onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()} onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)} onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)} onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => setLeftSidebarOpen(false)} onToggleSidebar={() => setLeftSidebarOpen(false)}
/> />
</div> </div>
@ -2169,14 +2144,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
onSelectNewTaskRepo={setSelectedNewTaskRepoId} onSelectNewTaskRepo={setSelectedNewTaskRepoId}
onMarkUnread={markTaskUnread} onMarkUnread={markTaskUnread}
onRenameTask={renameTask} onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderRepositories={reorderRepositories} onReorderRepositories={reorderRepositories}
taskOrderByRepository={taskOrderByRepository} taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks} onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()} onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()} onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)} onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)} onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => { onToggleSidebar={() => {
setLeftSidebarPeeking(false); setLeftSidebarPeeking(false);
setLeftSidebarOpen(true); setLeftSidebarOpen(true);
@ -2189,7 +2163,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
{leftSidebarOpen ? <PanelResizeHandle onResizeStart={onLeftResizeStart} onResize={onLeftResize} /> : null} {leftSidebarOpen ? <PanelResizeHandle onResizeStart={onLeftResizeStart} onResize={onLeftResize} /> : null}
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}> <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<TranscriptPanel <TranscriptPanel
taskWorkbenchClient={taskWorkbenchClient} taskWorkspaceClient={taskWorkspaceClient}
task={activeTask} task={activeTask}
hasSandbox={hasSandbox} hasSandbox={hasSandbox}
activeSessionId={activeSessionId} activeSessionId={activeSessionId}
@ -2248,7 +2222,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
{showDevPanel && ( {showDevPanel && (
<DevPanel <DevPanel
organizationId={organizationId} organizationId={organizationId}
snapshot={{ organizationId, repos: organizationRepos, repositories: rawRepositories, tasks } as TaskWorkbenchSnapshot} snapshot={{ organizationId, repos: organizationRepos, repositories: rawRepositories, tasks } as TaskWorkspaceSnapshot}
organization={activeOrg} organization={activeOrg}
focusedTask={{ focusedTask={{
id: activeTask.id, id: activeTask.id,

View file

@ -68,7 +68,6 @@ export const Sidebar = memo(function Sidebar({
onSelectNewTaskRepo, onSelectNewTaskRepo,
onMarkUnread, onMarkUnread,
onRenameTask, onRenameTask,
onRenameBranch,
onReorderRepositories, onReorderRepositories,
taskOrderByRepository, taskOrderByRepository,
onReorderTasks, onReorderTasks,
@ -87,7 +86,6 @@ export const Sidebar = memo(function Sidebar({
onSelectNewTaskRepo: (repoId: string) => void; onSelectNewTaskRepo: (repoId: string) => void;
onMarkUnread: (id: string) => void; onMarkUnread: (id: string) => void;
onRenameTask: (id: string) => void; onRenameTask: (id: string) => void;
onRenameBranch: (id: string) => void;
onReorderRepositories: (fromIndex: number, toIndex: number) => void; onReorderRepositories: (fromIndex: number, toIndex: number) => void;
taskOrderByRepository: Record<string, string[]>; taskOrderByRepository: Record<string, string[]>;
onReorderTasks: (repositoryId: string, fromIndex: number, toIndex: number) => void; onReorderTasks: (repositoryId: string, fromIndex: number, toIndex: number) => void;
@ -729,7 +727,6 @@ export const Sidebar = memo(function Sidebar({
} }
contextMenu.open(event, [ contextMenu.open(event, [
{ label: "Rename task", onClick: () => onRenameTask(task.id) }, { label: "Rename task", onClick: () => onRenameTask(task.id) },
{ label: "Rename branch", onClick: () => onRenameBranch(task.id) },
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) }, { label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
]); ]);
}} }}

View file

@ -30,11 +30,11 @@ export const TranscriptHeader = memo(function TranscriptHeader({
task: Task; task: Task;
hasSandbox: boolean; hasSandbox: boolean;
activeSession: AgentSession | null | undefined; activeSession: AgentSession | null | undefined;
editingField: "title" | "branch" | null; editingField: "title" | null;
editValue: string; editValue: string;
onEditValueChange: (value: string) => void; onEditValueChange: (value: string) => void;
onStartEditingField: (field: "title" | "branch", value: string) => void; onStartEditingField: (field: "title", value: string) => void;
onCommitEditingField: (field: "title" | "branch") => void; onCommitEditingField: (field: "title") => void;
onCancelEditingField: () => void; onCancelEditingField: () => void;
onSetActiveSessionUnread: (unread: boolean) => void; onSetActiveSessionUnread: (unread: boolean) => void;
sidebarCollapsed?: boolean; sidebarCollapsed?: boolean;
@ -118,39 +118,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
</LabelSmall> </LabelSmall>
)} )}
{task.branch ? ( {task.branch ? (
editingField === "branch" ? (
<input
autoFocus
value={editValue}
onChange={(event) => onEditValueChange(event.target.value)}
onBlur={() => onCommitEditingField("branch")}
onKeyDown={(event) => {
if (event.key === "Enter") {
onCommitEditingField("branch");
} else if (event.key === "Escape") {
onCancelEditingField();
}
}}
className={css({
appearance: "none",
WebkitAppearance: "none",
margin: "0",
outline: "none",
padding: "2px 8px",
borderRadius: "999px",
border: `1px solid ${t.borderFocus}`,
backgroundColor: t.interactiveSubtle,
color: t.textPrimary,
fontSize: "11px",
whiteSpace: "nowrap",
fontFamily: '"IBM Plex Mono", monospace',
minWidth: "60px",
})}
/>
) : (
<span <span
title="Rename"
onClick={() => onStartEditingField("branch", task.branch ?? "")}
className={css({ className={css({
padding: "2px 8px", padding: "2px 8px",
borderRadius: "999px", borderRadius: "999px",
@ -160,13 +128,10 @@ export const TranscriptHeader = memo(function TranscriptHeader({
fontSize: "11px", fontSize: "11px",
whiteSpace: "nowrap", whiteSpace: "nowrap",
fontFamily: '"IBM Plex Mono", monospace', fontFamily: '"IBM Plex Mono", monospace',
cursor: "pointer",
":hover": { borderColor: t.borderFocus },
})} })}
> >
{task.branch} {task.branch}
</span> </span>
)
) : null} ) : null}
<HeaderStatusPill status={headerStatus} /> <HeaderStatusPill status={headerStatus} />
<div className={css({ flex: 1 })} /> <div className={css({ flex: 1 })} />

View file

@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { WorkbenchSession } from "@sandbox-agent/foundry-shared"; import type { WorkspaceSession } from "@sandbox-agent/foundry-shared";
import { buildDisplayMessages } from "./view-model"; import { buildDisplayMessages } from "./view-model";
function makeSession(transcript: WorkbenchSession["transcript"]): WorkbenchSession { function makeSession(transcript: WorkspaceSession["transcript"]): WorkspaceSession {
return { return {
id: "session-1", id: "session-1",
sessionId: "session-1", sessionId: "session-1",

View file

@ -1,17 +1,17 @@
import type { import type {
WorkbenchAgentKind as AgentKind, WorkspaceAgentKind as AgentKind,
WorkbenchSession as AgentSession, WorkspaceSession as AgentSession,
WorkbenchDiffLineKind as DiffLineKind, WorkspaceDiffLineKind as DiffLineKind,
WorkbenchFileChange as FileChange, WorkspaceFileChange as FileChange,
WorkbenchFileTreeNode as FileTreeNode, WorkspaceFileTreeNode as FileTreeNode,
WorkbenchTask as Task, WorkspaceTask as Task,
WorkbenchHistoryEvent as HistoryEvent, WorkspaceHistoryEvent as HistoryEvent,
WorkbenchLineAttachment as LineAttachment, WorkspaceLineAttachment as LineAttachment,
WorkbenchModelGroup as ModelGroup, WorkspaceModelGroup as ModelGroup,
WorkbenchModelId as ModelId, WorkspaceModelId as ModelId,
WorkbenchParsedDiffLine as ParsedDiffLine, WorkspaceParsedDiffLine as ParsedDiffLine,
WorkbenchRepositorySection as RepositorySection, WorkspaceRepositorySection as RepositorySection,
WorkbenchTranscriptEvent as TranscriptEvent, WorkspaceTranscriptEvent as TranscriptEvent,
} from "@sandbox-agent/foundry-shared"; } from "@sandbox-agent/foundry-shared";
import { extractEventText } from "../../features/sessions/model"; import { extractEventText } from "../../features/sessions/model";

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentType, RepoBranchRecord, RepoOverview, TaskWorkbenchSnapshot, WorkbenchTaskStatus } from "@sandbox-agent/foundry-shared"; import type { AgentType, RepoBranchRecord, RepoOverview, TaskWorkspaceSnapshot, WorkspaceTaskStatus } from "@sandbox-agent/foundry-shared";
import { currentFoundryOrganization, useSubscription } from "@sandbox-agent/foundry-client"; import { currentFoundryOrganization, useSubscription } from "@sandbox-agent/foundry-client";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router"; import { Link, useNavigate } from "@tanstack/react-router";
@ -100,7 +100,7 @@ const AGENT_OPTIONS: SelectItem[] = [
{ id: "claude", label: "claude" }, { id: "claude", label: "claude" },
]; ];
function statusKind(status: WorkbenchTaskStatus): StatusTagKind { function statusKind(status: WorkspaceTaskStatus): StatusTagKind {
if (status === "running") return "positive"; if (status === "running") return "positive";
if (status === "error") return "negative"; if (status === "error") return "negative";
if (status === "new" || String(status).startsWith("init_")) return "warning"; if (status === "new" || String(status).startsWith("init_")) return "warning";
@ -515,7 +515,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
}; };
}, [repoOverviewMode, selectedForSession, selectedSummary]); }, [repoOverviewMode, selectedForSession, selectedSummary]);
const devPanelSnapshot = useMemo( const devPanelSnapshot = useMemo(
(): TaskWorkbenchSnapshot => ({ (): TaskWorkspaceSnapshot => ({
organizationId, organizationId,
repos: repos.map((repo) => ({ id: repo.id, label: repo.label })), repos: repos.map((repo) => ({ id: repo.id, label: repo.label })),
repositories: [], repositories: [],

View file

@ -1,4 +1,4 @@
import type { TaskStatus, WorkbenchSessionStatus } from "@sandbox-agent/foundry-shared"; import type { TaskStatus, WorkspaceSessionStatus } from "@sandbox-agent/foundry-shared";
import type { HeaderStatusInfo } from "../../components/mock-layout/ui"; import type { HeaderStatusInfo } from "../../components/mock-layout/ui";
export type TaskDisplayStatus = TaskStatus | "new"; export type TaskDisplayStatus = TaskStatus | "new";
@ -73,7 +73,7 @@ export function describeTaskState(status: TaskDisplayStatus | null | undefined,
export function deriveHeaderStatus( export function deriveHeaderStatus(
taskStatus: TaskDisplayStatus | null | undefined, taskStatus: TaskDisplayStatus | null | undefined,
taskStatusMessage: string | null | undefined, taskStatusMessage: string | null | undefined,
sessionStatus: WorkbenchSessionStatus | null | undefined, sessionStatus: WorkspaceSessionStatus | null | undefined,
sessionErrorMessage: string | null | undefined, sessionErrorMessage: string | null | undefined,
hasSandbox?: boolean, hasSandbox?: boolean,
): HeaderStatusInfo { ): HeaderStatusInfo {

View file

@ -7,7 +7,13 @@ import {
eligibleFoundryOrganizations, eligibleFoundryOrganizations,
type FoundryAppClient, type FoundryAppClient,
} from "@sandbox-agent/foundry-client"; } from "@sandbox-agent/foundry-client";
import type { FoundryAppSnapshot, FoundryBillingPlanId, FoundryOrganization, UpdateFoundryOrganizationProfileInput } from "@sandbox-agent/foundry-shared"; import type {
FoundryAppSnapshot,
FoundryBillingPlanId,
FoundryOrganization,
UpdateFoundryOrganizationProfileInput,
WorkspaceModelId,
} from "@sandbox-agent/foundry-shared";
import { backendClient } from "./backend"; import { backendClient } from "./backend";
import { subscriptionManager } from "./subscription"; import { subscriptionManager } from "./subscription";
import { frontendClientMode } from "./env"; import { frontendClientMode } from "./env";
@ -58,6 +64,9 @@ const remoteAppClient: FoundryAppClient = {
async selectOrganization(organizationId: string): Promise<void> { async selectOrganization(organizationId: string): Promise<void> {
await backendClient.selectAppOrganization(organizationId); await backendClient.selectAppOrganization(organizationId);
}, },
async setDefaultModel(defaultModel: WorkspaceModelId): Promise<void> {
await backendClient.setAppDefaultModel(defaultModel);
},
async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> { async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> {
await backendClient.updateAppOrganizationProfile(input); await backendClient.updateAppOrganizationProfile(input);
}, },

View file

@ -43,15 +43,27 @@ declare module "@sandbox-agent/react" {
className?: string; className?: string;
classNames?: Partial<AgentTranscriptClassNames>; classNames?: Partial<AgentTranscriptClassNames>;
endRef?: RefObject<HTMLDivElement>; endRef?: RefObject<HTMLDivElement>;
scrollRef?: RefObject<HTMLDivElement>;
scrollToEntryId?: string | null;
sessionError?: string | null; sessionError?: string | null;
eventError?: string | null; eventError?: string | null;
isThinking?: boolean; isThinking?: boolean;
agentId?: string; agentId?: string;
virtualize?: boolean;
onAtBottomChange?: (atBottom: boolean) => void;
onEventClick?: (eventId: string) => void; onEventClick?: (eventId: string) => void;
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void; onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
isDividerEntry?: (entry: TranscriptEntry) => boolean;
canOpenEvent?: (entry: TranscriptEntry) => boolean;
getToolGroupSummary?: (entries: TranscriptEntry[]) => string;
renderMessageText?: (entry: TranscriptEntry) => ReactNode; renderMessageText?: (entry: TranscriptEntry) => ReactNode;
renderInlinePendingIndicator?: () => ReactNode; renderInlinePendingIndicator?: () => ReactNode;
renderThinkingState?: (context: { agentId?: string }) => ReactNode; renderThinkingState?: (context: { agentId?: string }) => ReactNode;
renderToolItemIcon?: (entry: TranscriptEntry) => ReactNode;
renderToolGroupIcon?: (entries: TranscriptEntry[], expanded: boolean) => ReactNode;
renderChevron?: (expanded: boolean) => ReactNode;
renderEventLinkContent?: (entry: TranscriptEntry) => ReactNode;
renderPermissionIcon?: (entry: TranscriptEntry) => ReactNode;
renderPermissionOptionContent?: (context: PermissionOptionRenderContext) => ReactNode; renderPermissionOptionContent?: (context: PermissionOptionRenderContext) => ReactNode;
} }

View file

@ -1,4 +1,4 @@
import type { WorkbenchModelId } from "./workbench.js"; import type { WorkspaceModelId } from "./workspace.js";
export type FoundryBillingPlanId = "free" | "team"; export type FoundryBillingPlanId = "free" | "team";
export type FoundryBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel"; export type FoundryBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel";
@ -14,6 +14,7 @@ export interface FoundryUser {
githubLogin: string; githubLogin: string;
roleLabel: string; roleLabel: string;
eligibleOrganizationIds: string[]; eligibleOrganizationIds: string[];
defaultModel: WorkspaceModelId;
} }
export interface FoundryOrganizationMember { export interface FoundryOrganizationMember {
@ -59,7 +60,6 @@ export interface FoundryOrganizationSettings {
slug: string; slug: string;
primaryDomain: string; primaryDomain: string;
seatAccrualMode: "first_prompt"; seatAccrualMode: "first_prompt";
defaultModel: WorkbenchModelId;
autoImportRepos: boolean; autoImportRepos: boolean;
} }

View file

@ -54,7 +54,6 @@ export const CreateTaskInputSchema = z.object({
explicitTitle: z.string().trim().min(1).optional(), explicitTitle: z.string().trim().min(1).optional(),
explicitBranchName: z.string().trim().min(1).optional(), explicitBranchName: z.string().trim().min(1).optional(),
sandboxProviderId: SandboxProviderIdSchema.optional(), sandboxProviderId: SandboxProviderIdSchema.optional(),
agentType: AgentTypeSchema.optional(),
onBranch: z.string().trim().min(1).optional(), onBranch: z.string().trim().min(1).optional(),
}); });
export type CreateTaskInput = z.infer<typeof CreateTaskInputSchema>; export type CreateTaskInput = z.infer<typeof CreateTaskInputSchema>;
@ -69,9 +68,7 @@ export const TaskRecordSchema = z.object({
task: z.string().min(1), task: z.string().min(1),
sandboxProviderId: SandboxProviderIdSchema, sandboxProviderId: SandboxProviderIdSchema,
status: TaskStatusSchema, status: TaskStatusSchema,
statusMessage: z.string().nullable(),
activeSandboxId: z.string().nullable(), activeSandboxId: z.string().nullable(),
activeSessionId: z.string().nullable(),
sandboxes: z.array( sandboxes: z.array(
z.object({ z.object({
sandboxId: z.string().min(1), sandboxId: z.string().min(1),
@ -83,17 +80,12 @@ export const TaskRecordSchema = z.object({
updatedAt: z.number().int(), updatedAt: z.number().int(),
}), }),
), ),
agentType: z.string().nullable(),
prSubmitted: z.boolean(),
diffStat: z.string().nullable(), diffStat: z.string().nullable(),
prUrl: z.string().nullable(), prUrl: z.string().nullable(),
prAuthor: z.string().nullable(), prAuthor: z.string().nullable(),
ciStatus: z.string().nullable(), ciStatus: z.string().nullable(),
reviewStatus: z.string().nullable(), reviewStatus: z.string().nullable(),
reviewer: z.string().nullable(), reviewer: z.string().nullable(),
conflictsWithMain: z.string().nullable(),
hasUnpushed: z.string().nullable(),
parentBranch: z.string().nullable(),
createdAt: z.number().int(), createdAt: z.number().int(),
updatedAt: z.number().int(), updatedAt: z.number().int(),
}); });
@ -112,6 +104,7 @@ export type TaskSummary = z.infer<typeof TaskSummarySchema>;
export const TaskActionInputSchema = z.object({ export const TaskActionInputSchema = z.object({
organizationId: OrganizationIdSchema, organizationId: OrganizationIdSchema,
repoId: RepoIdSchema,
taskId: z.string().min(1), taskId: z.string().min(1),
}); });
export type TaskActionInput = z.infer<typeof TaskActionInputSchema>; export type TaskActionInput = z.infer<typeof TaskActionInputSchema>;
@ -180,7 +173,7 @@ export const HistoryQueryInputSchema = z.object({
}); });
export type HistoryQueryInput = z.infer<typeof HistoryQueryInputSchema>; export type HistoryQueryInput = z.infer<typeof HistoryQueryInputSchema>;
export const HistoryEventSchema = z.object({ export const AuditLogEventSchema = z.object({
id: z.number().int(), id: z.number().int(),
organizationId: OrganizationIdSchema, organizationId: OrganizationIdSchema,
repoId: z.string().nullable(), repoId: z.string().nullable(),
@ -190,7 +183,7 @@ export const HistoryEventSchema = z.object({
payloadJson: z.string().min(1), payloadJson: z.string().min(1),
createdAt: z.number().int(), createdAt: z.number().int(),
}); });
export type HistoryEvent = z.infer<typeof HistoryEventSchema>; export type AuditLogEvent = z.infer<typeof AuditLogEventSchema>;
export const PruneInputSchema = z.object({ export const PruneInputSchema = z.object({
organizationId: OrganizationIdSchema, organizationId: OrganizationIdSchema,
@ -201,6 +194,7 @@ export type PruneInput = z.infer<typeof PruneInputSchema>;
export const KillInputSchema = z.object({ export const KillInputSchema = z.object({
organizationId: OrganizationIdSchema, organizationId: OrganizationIdSchema,
repoId: RepoIdSchema,
taskId: z.string().min(1), taskId: z.string().min(1),
deleteBranch: z.boolean(), deleteBranch: z.boolean(),
abandon: z.boolean(), abandon: z.boolean(),

View file

@ -3,5 +3,5 @@ export * from "./contracts.js";
export * from "./config.js"; export * from "./config.js";
export * from "./logging.js"; export * from "./logging.js";
export * from "./realtime-events.js"; export * from "./realtime-events.js";
export * from "./workbench.js"; export * from "./workspace.js";
export * from "./organization.js"; export * from "./organization.js";

View file

@ -1,5 +1,5 @@
import type { FoundryAppSnapshot } from "./app-shell.js"; import type { FoundryAppSnapshot } from "./app-shell.js";
import type { WorkbenchOpenPrSummary, WorkbenchRepositorySummary, WorkbenchSessionDetail, WorkbenchTaskDetail, WorkbenchTaskSummary } from "./workbench.js"; import type { OrganizationSummarySnapshot, WorkspaceSessionDetail, WorkspaceTaskDetail } from "./workspace.js";
export interface SandboxProcessSnapshot { export interface SandboxProcessSnapshot {
id: string; id: string;
@ -16,20 +16,13 @@ export interface SandboxProcessSnapshot {
} }
/** Organization-level events broadcast by the organization actor. */ /** Organization-level events broadcast by the organization actor. */
export type OrganizationEvent = export type OrganizationEvent = { type: "organizationUpdated"; snapshot: OrganizationSummarySnapshot };
| { type: "taskSummaryUpdated"; taskSummary: WorkbenchTaskSummary }
| { type: "taskRemoved"; taskId: string }
| { type: "repoAdded"; repo: WorkbenchRepositorySummary }
| { type: "repoUpdated"; repo: WorkbenchRepositorySummary }
| { type: "repoRemoved"; repoId: string }
| { type: "pullRequestUpdated"; pullRequest: WorkbenchOpenPrSummary }
| { type: "pullRequestRemoved"; prId: string };
/** Task-level events broadcast by the task actor. */ /** Task-level events broadcast by the task actor. */
export type TaskEvent = { type: "taskDetailUpdated"; detail: WorkbenchTaskDetail }; export type TaskEvent = { type: "taskUpdated"; detail: WorkspaceTaskDetail };
/** Session-level events broadcast by the task actor and filtered by sessionId on the client. */ /** Session-level events broadcast by the task actor and filtered by sessionId on the client. */
export type SessionEvent = { type: "sessionUpdated"; session: WorkbenchSessionDetail }; export type SessionEvent = { type: "sessionUpdated"; session: WorkspaceSessionDetail };
/** App-level events broadcast by the app organization actor. */ /** App-level events broadcast by the app organization actor. */
export type AppEvent = { type: "appUpdated"; snapshot: FoundryAppSnapshot }; export type AppEvent = { type: "appUpdated"; snapshot: FoundryAppSnapshot };

View file

@ -1,8 +1,8 @@
import type { AgentType, SandboxProviderId, TaskStatus } from "./contracts.js"; import type { SandboxProviderId, TaskStatus } from "./contracts.js";
export type WorkbenchTaskStatus = TaskStatus | "new"; export type WorkspaceTaskStatus = TaskStatus | "new";
export type WorkbenchAgentKind = "Claude" | "Codex" | "Cursor"; export type WorkspaceAgentKind = "Claude" | "Codex" | "Cursor";
export type WorkbenchModelId = export type WorkspaceModelId =
| "claude-sonnet-4" | "claude-sonnet-4"
| "claude-opus-4" | "claude-opus-4"
| "gpt-5.3-codex" | "gpt-5.3-codex"
@ -11,9 +11,9 @@ export type WorkbenchModelId =
| "gpt-5.1-codex-max" | "gpt-5.1-codex-max"
| "gpt-5.2" | "gpt-5.2"
| "gpt-5.1-codex-mini"; | "gpt-5.1-codex-mini";
export type WorkbenchSessionStatus = "pending_provision" | "pending_session_create" | "ready" | "running" | "idle" | "error"; export type WorkspaceSessionStatus = "pending_provision" | "pending_session_create" | "ready" | "running" | "idle" | "error";
export interface WorkbenchTranscriptEvent { export interface WorkspaceTranscriptEvent {
id: string; id: string;
eventIndex: number; eventIndex: number;
sessionId: string; sessionId: string;
@ -23,23 +23,23 @@ export interface WorkbenchTranscriptEvent {
payload: unknown; payload: unknown;
} }
export interface WorkbenchComposerDraft { export interface WorkspaceComposerDraft {
text: string; text: string;
attachments: WorkbenchLineAttachment[]; attachments: WorkspaceLineAttachment[];
updatedAtMs: number | null; updatedAtMs: number | null;
} }
/** Session metadata without transcript content. */ /** Session metadata without transcript content. */
export interface WorkbenchSessionSummary { export interface WorkspaceSessionSummary {
id: string; id: string;
/** Stable UI session id used for routing and task-local identity. */ /** Stable UI session id used for routing and task-local identity. */
sessionId: string; sessionId: string;
/** Underlying sandbox session id when provisioning has completed. */ /** Underlying sandbox session id when provisioning has completed. */
sandboxSessionId?: string | null; sandboxSessionId?: string | null;
sessionName: string; sessionName: string;
agent: WorkbenchAgentKind; agent: WorkspaceAgentKind;
model: WorkbenchModelId; model: WorkspaceModelId;
status: WorkbenchSessionStatus; status: WorkspaceSessionStatus;
thinkingSinceMs: number | null; thinkingSinceMs: number | null;
unread: boolean; unread: boolean;
created: boolean; created: boolean;
@ -47,44 +47,44 @@ export interface WorkbenchSessionSummary {
} }
/** Full session content — only fetched when viewing a specific session. */ /** Full session content — only fetched when viewing a specific session. */
export interface WorkbenchSessionDetail { export interface WorkspaceSessionDetail {
/** Stable UI session id used for the session topic key and routing. */ /** Stable UI session id used for the session topic key and routing. */
sessionId: string; sessionId: string;
sandboxSessionId: string | null; sandboxSessionId: string | null;
sessionName: string; sessionName: string;
agent: WorkbenchAgentKind; agent: WorkspaceAgentKind;
model: WorkbenchModelId; model: WorkspaceModelId;
status: WorkbenchSessionStatus; status: WorkspaceSessionStatus;
thinkingSinceMs: number | null; thinkingSinceMs: number | null;
unread: boolean; unread: boolean;
created: boolean; created: boolean;
errorMessage?: string | null; errorMessage?: string | null;
draft: WorkbenchComposerDraft; draft: WorkspaceComposerDraft;
transcript: WorkbenchTranscriptEvent[]; transcript: WorkspaceTranscriptEvent[];
} }
export interface WorkbenchFileChange { export interface WorkspaceFileChange {
path: string; path: string;
added: number; added: number;
removed: number; removed: number;
type: "M" | "A" | "D"; type: "M" | "A" | "D";
} }
export interface WorkbenchFileTreeNode { export interface WorkspaceFileTreeNode {
name: string; name: string;
path: string; path: string;
isDir: boolean; isDir: boolean;
children?: WorkbenchFileTreeNode[]; children?: WorkspaceFileTreeNode[];
} }
export interface WorkbenchLineAttachment { export interface WorkspaceLineAttachment {
id: string; id: string;
filePath: string; filePath: string;
lineNumber: number; lineNumber: number;
lineContent: string; lineContent: string;
} }
export interface WorkbenchHistoryEvent { export interface WorkspaceHistoryEvent {
id: string; id: string;
messageId: string; messageId: string;
preview: string; preview: string;
@ -94,78 +94,67 @@ export interface WorkbenchHistoryEvent {
detail: string; detail: string;
} }
export type WorkbenchDiffLineKind = "context" | "add" | "remove" | "hunk"; export type WorkspaceDiffLineKind = "context" | "add" | "remove" | "hunk";
export interface WorkbenchParsedDiffLine { export interface WorkspaceParsedDiffLine {
kind: WorkbenchDiffLineKind; kind: WorkspaceDiffLineKind;
lineNumber: number; lineNumber: number;
text: string; text: string;
} }
export interface WorkbenchPullRequestSummary { export interface WorkspacePullRequestSummary {
number: number;
status: "draft" | "ready";
}
export interface WorkbenchOpenPrSummary {
prId: string;
repoId: string;
repoFullName: string;
number: number; number: number;
title: string; title: string;
state: string; state: string;
url: string; url: string;
headRefName: string; headRefName: string;
baseRefName: string; baseRefName: string;
repoFullName: string;
authorLogin: string | null; authorLogin: string | null;
isDraft: boolean; isDraft: boolean;
updatedAtMs: number; updatedAtMs: number;
} }
export interface WorkbenchSandboxSummary { export interface WorkspaceSandboxSummary {
sandboxProviderId: SandboxProviderId; sandboxProviderId: SandboxProviderId;
sandboxId: string; sandboxId: string;
cwd: string | null; cwd: string | null;
} }
/** Sidebar-level task data. Materialized in the organization actor's SQLite. */ /** Sidebar-level task data. Materialized in the organization actor's SQLite. */
export interface WorkbenchTaskSummary { export interface WorkspaceTaskSummary {
id: string; id: string;
repoId: string; repoId: string;
title: string; title: string;
status: WorkbenchTaskStatus; status: WorkspaceTaskStatus;
repoName: string; repoName: string;
updatedAtMs: number; updatedAtMs: number;
branch: string | null; branch: string | null;
pullRequest: WorkbenchPullRequestSummary | null; pullRequest: WorkspacePullRequestSummary | null;
/** Summary of sessions — no transcript content. */ /** Summary of sessions — no transcript content. */
sessionsSummary: WorkbenchSessionSummary[]; sessionsSummary: WorkspaceSessionSummary[];
} }
/** Full task detail — only fetched when viewing a specific task. */ /** Full task detail — only fetched when viewing a specific task. */
export interface WorkbenchTaskDetail extends WorkbenchTaskSummary { export interface WorkspaceTaskDetail extends WorkspaceTaskSummary {
/** Original task prompt/instructions shown in the detail view. */ /** Original task prompt/instructions shown in the detail view. */
task: string; task: string;
/** Agent choice used when creating new sandbox sessions for this task. */
agentType: AgentType | null;
/** Underlying task runtime status preserved for detail views and error handling. */ /** Underlying task runtime status preserved for detail views and error handling. */
runtimeStatus: TaskStatus; runtimeStatus: TaskStatus;
statusMessage: string | null;
activeSessionId: string | null;
diffStat: string | null; diffStat: string | null;
prUrl: string | null; prUrl: string | null;
reviewStatus: string | null; reviewStatus: string | null;
fileChanges: WorkbenchFileChange[]; fileChanges: WorkspaceFileChange[];
diffs: Record<string, string>; diffs: Record<string, string>;
fileTree: WorkbenchFileTreeNode[]; fileTree: WorkspaceFileTreeNode[];
minutesUsed: number; minutesUsed: number;
/** Sandbox info for this task. */ /** Sandbox info for this task. */
sandboxes: WorkbenchSandboxSummary[]; sandboxes: WorkspaceSandboxSummary[];
activeSandboxId: string | null; activeSandboxId: string | null;
} }
/** Repo-level summary for organization sidebar. */ /** Repo-level summary for organization sidebar. */
export interface WorkbenchRepositorySummary { export interface WorkspaceRepositorySummary {
id: string; id: string;
label: string; label: string;
/** Aggregated branch/task overview state (replaces getRepoOverview polling). */ /** Aggregated branch/task overview state (replaces getRepoOverview polling). */
@ -176,121 +165,126 @@ export interface WorkbenchRepositorySummary {
/** Organization-level snapshot — initial fetch for the organization topic. */ /** Organization-level snapshot — initial fetch for the organization topic. */
export interface OrganizationSummarySnapshot { export interface OrganizationSummarySnapshot {
organizationId: string; organizationId: string;
repos: WorkbenchRepositorySummary[]; repos: WorkspaceRepositorySummary[];
taskSummaries: WorkbenchTaskSummary[]; taskSummaries: WorkspaceTaskSummary[];
openPullRequests: WorkbenchOpenPrSummary[];
} }
export interface WorkbenchSession extends WorkbenchSessionSummary { export interface WorkspaceSession extends WorkspaceSessionSummary {
draft: WorkbenchComposerDraft; draft: WorkspaceComposerDraft;
transcript: WorkbenchTranscriptEvent[]; transcript: WorkspaceTranscriptEvent[];
} }
export interface WorkbenchTask { export interface WorkspaceTask {
id: string; id: string;
repoId: string; repoId: string;
title: string; title: string;
status: WorkbenchTaskStatus; status: WorkspaceTaskStatus;
runtimeStatus?: TaskStatus; runtimeStatus?: TaskStatus;
statusMessage?: string | null;
repoName: string; repoName: string;
updatedAtMs: number; updatedAtMs: number;
branch: string | null; branch: string | null;
pullRequest: WorkbenchPullRequestSummary | null; pullRequest: WorkspacePullRequestSummary | null;
sessions: WorkbenchSession[]; sessions: WorkspaceSession[];
fileChanges: WorkbenchFileChange[]; fileChanges: WorkspaceFileChange[];
diffs: Record<string, string>; diffs: Record<string, string>;
fileTree: WorkbenchFileTreeNode[]; fileTree: WorkspaceFileTreeNode[];
minutesUsed: number; minutesUsed: number;
activeSandboxId?: string | null; activeSandboxId?: string | null;
} }
export interface WorkbenchRepo { export interface WorkspaceRepo {
id: string; id: string;
label: string; label: string;
} }
export interface WorkbenchRepositorySection { export interface WorkspaceRepositorySection {
id: string; id: string;
label: string; label: string;
updatedAtMs: number; updatedAtMs: number;
tasks: WorkbenchTask[]; tasks: WorkspaceTask[];
} }
export interface TaskWorkbenchSnapshot { export interface TaskWorkspaceSnapshot {
organizationId: string; organizationId: string;
repos: WorkbenchRepo[]; repos: WorkspaceRepo[];
repositories: WorkbenchRepositorySection[]; repositories: WorkspaceRepositorySection[];
tasks: WorkbenchTask[]; tasks: WorkspaceTask[];
} }
export interface WorkbenchModelOption { export interface WorkspaceModelOption {
id: WorkbenchModelId; id: WorkspaceModelId;
label: string; label: string;
} }
export interface WorkbenchModelGroup { export interface WorkspaceModelGroup {
provider: string; provider: string;
models: WorkbenchModelOption[]; models: WorkspaceModelOption[];
} }
export interface TaskWorkbenchSelectInput { export interface TaskWorkspaceSelectInput {
repoId: string;
taskId: string; taskId: string;
authSessionId?: string;
} }
export interface TaskWorkbenchCreateTaskInput { export interface TaskWorkspaceCreateTaskInput {
repoId: string; repoId: string;
task: string; task: string;
title?: string; title?: string;
branch?: string; branch?: string;
onBranch?: string; onBranch?: string;
model?: WorkbenchModelId; model?: WorkspaceModelId;
authSessionId?: string;
} }
export interface TaskWorkbenchRenameInput { export interface TaskWorkspaceRenameInput {
repoId: string;
taskId: string; taskId: string;
value: string; value: string;
} }
export interface TaskWorkbenchSendMessageInput { export interface TaskWorkspaceSendMessageInput {
taskId: string; taskId: string;
sessionId: string; sessionId: string;
text: string; text: string;
attachments: WorkbenchLineAttachment[]; attachments: WorkspaceLineAttachment[];
authSessionId?: string;
} }
export interface TaskWorkbenchSessionInput { export interface TaskWorkspaceSessionInput {
taskId: string; taskId: string;
sessionId: string; sessionId: string;
authSessionId?: string;
} }
export interface TaskWorkbenchRenameSessionInput extends TaskWorkbenchSessionInput { export interface TaskWorkspaceRenameSessionInput extends TaskWorkspaceSessionInput {
title: string; title: string;
} }
export interface TaskWorkbenchChangeModelInput extends TaskWorkbenchSessionInput { export interface TaskWorkspaceChangeModelInput extends TaskWorkspaceSessionInput {
model: WorkbenchModelId; model: WorkspaceModelId;
} }
export interface TaskWorkbenchUpdateDraftInput extends TaskWorkbenchSessionInput { export interface TaskWorkspaceUpdateDraftInput extends TaskWorkspaceSessionInput {
text: string; text: string;
attachments: WorkbenchLineAttachment[]; attachments: WorkspaceLineAttachment[];
} }
export interface TaskWorkbenchSetSessionUnreadInput extends TaskWorkbenchSessionInput { export interface TaskWorkspaceSetSessionUnreadInput extends TaskWorkspaceSessionInput {
unread: boolean; unread: boolean;
} }
export interface TaskWorkbenchDiffInput { export interface TaskWorkspaceDiffInput {
repoId: string;
taskId: string; taskId: string;
path: string; path: string;
} }
export interface TaskWorkbenchCreateTaskResponse { export interface TaskWorkspaceCreateTaskResponse {
taskId: string; taskId: string;
sessionId?: string; sessionId?: string;
} }
export interface TaskWorkbenchAddSessionResponse { export interface TaskWorkspaceAddSessionResponse {
sessionId: string; sessionId: string;
} }