mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 10:05:18 +00:00
Fix Foundry auth: migrate to Better Auth adapter, fix access token retrieval
- Remove @ts-nocheck from better-auth.ts, auth-user/index.ts, app-shell.ts and fix all type errors - Fix getAccessTokenForSession: read GitHub token directly from account record instead of calling Better Auth's internal /get-access-token endpoint which returns 403 on server-side calls - Re-implement workspaceAuth helper functions (workspaceAuthColumn, normalizeAuthValue, workspaceAuthClause, workspaceAuthWhere) that were accidentally deleted - Remove all retry logic (withRetries, isRetryableAppActorError) - Implement CORS origin allowlist from configured environment - Document cachedAppWorkspace singleton pattern - Add inline org sync fallback in buildAppSnapshot for post-OAuth flow - Add no-retry rule to CLAUDE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ae191d1ae1
commit
99c5b3eb5d
33 changed files with 3224 additions and 1104 deletions
|
|
@ -1,10 +1,14 @@
|
|||
# Load this file only when NODE_ENV=development.
|
||||
# The backend does not load dotenv files in production.
|
||||
# Foundry local development environment.
|
||||
# Copy ~/misc/the-foundry.env to .env in the repo root to populate secrets.
|
||||
# .env is gitignored — never commit it. The source of truth is ~/misc/the-foundry.env.
|
||||
#
|
||||
# Docker Compose (just foundry-dev) and the justfile (set dotenv-load := true)
|
||||
# both read .env automatically.
|
||||
|
||||
APP_URL=http://localhost:4173
|
||||
BETTER_AUTH_URL=http://localhost:4173
|
||||
BETTER_AUTH_SECRET=sandbox-agent-foundry-development-only-change-me
|
||||
GITHUB_REDIRECT_URI=http://localhost:4173/api/rivet/app/auth/github/callback
|
||||
GITHUB_REDIRECT_URI=http://localhost:4173/v1/auth/github/callback
|
||||
|
||||
# Fill these in when enabling live GitHub OAuth.
|
||||
GITHUB_CLIENT_ID=
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ For all Rivet/RivetKit implementation:
|
|||
- Request/action contract: wait only until the minimum resource needed for the client's next step exists. Example: task creation may wait for task actor creation/identity, but not for sandbox provisioning or session bootstrap.
|
||||
- Read paths must not force refresh/sync work inline. Serve the latest cached projection, mark staleness explicitly, and trigger background refresh separately when needed.
|
||||
- If a workflow needs to resume after some external work completes, model that as workflow state plus follow-up messages/events instead of holding the original request open.
|
||||
- Do not rely on retries for correctness or normal control flow. If a queue/workflow/external dependency is not ready yet, model that explicitly and resume from a push/event, instead of polling or retry loops.
|
||||
- No retries: never add retry loops (`withRetries`, `setTimeout` retry, exponential backoff) anywhere in the codebase. If an operation fails, surface the error immediately. If a dependency is not ready yet, model that explicitly with workflow state and resume from a push/event instead of polling or retry loops.
|
||||
- Actor handle policy:
|
||||
- Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`.
|
||||
- Use `get`/`getForId` when the actor is expected to already exist; if missing, surface an explicit `Actor not found` error with recovery context.
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ services:
|
|||
HF_DAYTONA_ENDPOINT: "${HF_DAYTONA_ENDPOINT:-}"
|
||||
HF_DAYTONA_API_KEY: "${HF_DAYTONA_API_KEY:-}"
|
||||
ports:
|
||||
- "6420:6420"
|
||||
- "7741:7741"
|
||||
volumes:
|
||||
- "..:/app"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"@iarna/toml": "^2.2.5",
|
||||
"@sandbox-agent/foundry-shared": "workspace:*",
|
||||
"@sandbox-agent/persist-rivet": "workspace:*",
|
||||
"better-auth": "^1.5.5",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"hono": "^4.11.9",
|
||||
|
|
|
|||
5
foundry/packages/backend/src/actors/auth-user/db/db.ts
Normal file
5
foundry/packages/backend/src/actors/auth-user/db/db.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { db } from "rivetkit/db/drizzle";
|
||||
import * as schema from "./schema.js";
|
||||
import migrations from "./migrations.js";
|
||||
|
||||
export const authUserDb = db({ schema, migrations });
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
// This file is generated by src/actors/_scripts/generate-actor-migrations.ts.
|
||||
// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql).
|
||||
// Do not hand-edit this file.
|
||||
|
||||
const journal = {
|
||||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
when: 1773446400000,
|
||||
tag: "0000_auth_user",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export default {
|
||||
journal,
|
||||
migrations: {
|
||||
m0000: `CREATE TABLE \`user\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`name\` text NOT NULL,
|
||||
\`email\` text NOT NULL,
|
||||
\`email_verified\` integer NOT NULL,
|
||||
\`image\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`session\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`token\` text NOT NULL,
|
||||
\`user_id\` text NOT NULL,
|
||||
\`expires_at\` integer NOT NULL,
|
||||
\`ip_address\` text,
|
||||
\`user_agent\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX \`session_token_idx\` ON \`session\` (\`token\`);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`account\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`account_id\` text NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`user_id\` text NOT NULL,
|
||||
\`access_token\` text,
|
||||
\`refresh_token\` text,
|
||||
\`id_token\` text,
|
||||
\`access_token_expires_at\` integer,
|
||||
\`refresh_token_expires_at\` integer,
|
||||
\`scope\` text,
|
||||
\`password\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX \`account_provider_account_idx\` ON \`account\` (\`provider_id\`, \`account_id\`);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`user_profiles\` (
|
||||
\`user_id\` text PRIMARY KEY NOT NULL,
|
||||
\`github_account_id\` text,
|
||||
\`github_login\` text,
|
||||
\`role_label\` text NOT NULL,
|
||||
\`eligible_organization_ids_json\` text NOT NULL,
|
||||
\`starter_repo_status\` text NOT NULL,
|
||||
\`starter_repo_starred_at\` integer,
|
||||
\`starter_repo_skipped_at\` integer,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`session_state\` (
|
||||
\`session_id\` text PRIMARY KEY NOT NULL,
|
||||
\`active_organization_id\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);`,
|
||||
} as const,
|
||||
};
|
||||
70
foundry/packages/backend/src/actors/auth-user/db/schema.ts
Normal file
70
foundry/packages/backend/src/actors/auth-user/db/schema.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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(),
|
||||
});
|
||||
353
foundry/packages/backend/src/actors/auth-user/index.ts
Normal file
353
foundry/packages/backend/src/actors/auth-user/index.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { and, asc, count as sqlCount, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, notInArray, or } from "drizzle-orm";
|
||||
import { actor } from "rivetkit";
|
||||
import { authUserDb } from "./db/db.js";
|
||||
import { authAccounts, authSessions, authUsers, sessionState, userProfiles } from "./db/schema.js";
|
||||
|
||||
const tables = {
|
||||
user: authUsers,
|
||||
session: authSessions,
|
||||
account: authAccounts,
|
||||
userProfiles,
|
||||
sessionState,
|
||||
} as const;
|
||||
|
||||
function tableFor(model: string) {
|
||||
const table = tables[model as keyof typeof tables];
|
||||
if (!table) {
|
||||
throw new Error(`Unsupported auth user model: ${model}`);
|
||||
}
|
||||
return table as any;
|
||||
}
|
||||
|
||||
function columnFor(table: any, field: string) {
|
||||
const column = table[field];
|
||||
if (!column) {
|
||||
throw new Error(`Unsupported auth user field: ${field}`);
|
||||
}
|
||||
return column;
|
||||
}
|
||||
|
||||
function normalizeValue(value: unknown): unknown {
|
||||
if (value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => normalizeValue(entry));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function clauseToExpr(table: any, clause: any) {
|
||||
const column = columnFor(table, clause.field);
|
||||
const value = normalizeValue(clause.value);
|
||||
|
||||
switch (clause.operator) {
|
||||
case "ne":
|
||||
return value === null ? isNotNull(column) : ne(column, value as any);
|
||||
case "lt":
|
||||
return lt(column, value as any);
|
||||
case "lte":
|
||||
return lte(column, value as any);
|
||||
case "gt":
|
||||
return gt(column, value as any);
|
||||
case "gte":
|
||||
return gte(column, value as any);
|
||||
case "in":
|
||||
return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
|
||||
case "not_in":
|
||||
return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
|
||||
case "contains":
|
||||
return like(column, `%${String(value ?? "")}%`);
|
||||
case "starts_with":
|
||||
return like(column, `${String(value ?? "")}%`);
|
||||
case "ends_with":
|
||||
return like(column, `%${String(value ?? "")}`);
|
||||
case "eq":
|
||||
default:
|
||||
return value === null ? isNull(column) : eq(column, value as any);
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhere(table: any, where: any[] | undefined) {
|
||||
if (!where || where.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let expr = clauseToExpr(table, where[0]);
|
||||
for (const clause of where.slice(1)) {
|
||||
const next = clauseToExpr(table, clause);
|
||||
expr = clause.connector === "OR" ? or(expr, next) : and(expr, next);
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
function applyJoinToRow(c: any, model: string, row: any, join: any) {
|
||||
if (!row || !join) {
|
||||
return row;
|
||||
}
|
||||
|
||||
if (model === "session" && join.user) {
|
||||
return c.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, row.userId))
|
||||
.get()
|
||||
.then((user: any) => ({ ...row, user: user ?? null }));
|
||||
}
|
||||
|
||||
if (model === "account" && join.user) {
|
||||
return c.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, row.userId))
|
||||
.get()
|
||||
.then((user: any) => ({ ...row, user: user ?? null }));
|
||||
}
|
||||
|
||||
if (model === "user" && join.account) {
|
||||
return c.db
|
||||
.select()
|
||||
.from(authAccounts)
|
||||
.where(eq(authAccounts.userId, row.id))
|
||||
.all()
|
||||
.then((accounts: any[]) => ({ ...row, account: accounts }));
|
||||
}
|
||||
|
||||
return Promise.resolve(row);
|
||||
}
|
||||
|
||||
async function applyJoinToRows(c: any, model: string, rows: any[], join: any) {
|
||||
if (!join || rows.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (model === "session" && join.user) {
|
||||
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
|
||||
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.id, userIds)).all() : [];
|
||||
const userMap = new Map(users.map((user: any) => [user.id, user]));
|
||||
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
|
||||
}
|
||||
|
||||
if (model === "account" && join.user) {
|
||||
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
|
||||
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.id, userIds)).all() : [];
|
||||
const userMap = new Map(users.map((user: any) => [user.id, user]));
|
||||
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
|
||||
}
|
||||
|
||||
if (model === "user" && join.account) {
|
||||
const userIds = rows.map((row) => row.id);
|
||||
const accounts = userIds.length > 0 ? await c.db.select().from(authAccounts).where(inArray(authAccounts.userId, userIds)).all() : [];
|
||||
const accountsByUserId = new Map<string, any[]>();
|
||||
for (const account of accounts) {
|
||||
const entries = accountsByUserId.get(account.userId) ?? [];
|
||||
entries.push(account);
|
||||
accountsByUserId.set(account.userId, entries);
|
||||
}
|
||||
return rows.map((row) => ({ ...row, account: accountsByUserId.get(row.id) ?? [] }));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export const authUser = actor({
|
||||
db: authUserDb,
|
||||
options: {
|
||||
name: "Auth User",
|
||||
icon: "shield",
|
||||
actionTimeout: 60_000,
|
||||
},
|
||||
createState: (_c, input: { userId: string }) => ({
|
||||
userId: input.userId,
|
||||
}),
|
||||
actions: {
|
||||
async createAuthRecord(c, input: { model: string; data: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
await c.db
|
||||
.insert(table)
|
||||
.values(input.data as any)
|
||||
.run();
|
||||
return await c.db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(columnFor(table, "id"), input.data.id as any))
|
||||
.get();
|
||||
},
|
||||
|
||||
async findOneAuthRecord(c, input: { model: string; where: any[]; join?: any }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
const row = predicate ? await c.db.select().from(table).where(predicate).get() : await c.db.select().from(table).get();
|
||||
return await applyJoinToRow(c, input.model, row ?? null, input.join);
|
||||
},
|
||||
|
||||
async findManyAuthRecords(c, input: { model: string; where?: any[]; limit?: number; offset?: number; sortBy?: any; join?: any }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
let query: any = c.db.select().from(table);
|
||||
if (predicate) {
|
||||
query = query.where(predicate);
|
||||
}
|
||||
if (input.sortBy?.field) {
|
||||
const column = columnFor(table, input.sortBy.field);
|
||||
query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column));
|
||||
}
|
||||
if (typeof input.limit === "number") {
|
||||
query = query.limit(input.limit);
|
||||
}
|
||||
if (typeof input.offset === "number") {
|
||||
query = query.offset(input.offset);
|
||||
}
|
||||
const rows = await query.all();
|
||||
return await applyJoinToRows(c, input.model, rows, input.join);
|
||||
},
|
||||
|
||||
async updateAuthRecord(c, input: { model: string; where: any[]; update: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) {
|
||||
throw new Error("updateAuthRecord requires a where clause");
|
||||
}
|
||||
await c.db
|
||||
.update(table)
|
||||
.set(input.update as any)
|
||||
.where(predicate)
|
||||
.run();
|
||||
return await c.db.select().from(table).where(predicate).get();
|
||||
},
|
||||
|
||||
async updateManyAuthRecords(c, input: { model: string; where: any[]; update: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) {
|
||||
throw new Error("updateManyAuthRecords requires a where clause");
|
||||
}
|
||||
await c.db
|
||||
.update(table)
|
||||
.set(input.update as any)
|
||||
.where(predicate)
|
||||
.run();
|
||||
const row = await c.db.select({ value: sqlCount() }).from(table).where(predicate).get();
|
||||
return row?.value ?? 0;
|
||||
},
|
||||
|
||||
async deleteAuthRecord(c, input: { model: string; where: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) {
|
||||
throw new Error("deleteAuthRecord requires a where clause");
|
||||
}
|
||||
await c.db.delete(table).where(predicate).run();
|
||||
},
|
||||
|
||||
async deleteManyAuthRecords(c, input: { model: string; where: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) {
|
||||
throw new Error("deleteManyAuthRecords requires a where clause");
|
||||
}
|
||||
const rows = await c.db.select().from(table).where(predicate).all();
|
||||
await c.db.delete(table).where(predicate).run();
|
||||
return rows.length;
|
||||
},
|
||||
|
||||
async countAuthRecords(c, input: { model: string; where?: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
const row = predicate
|
||||
? await c.db.select({ value: sqlCount() }).from(table).where(predicate).get()
|
||||
: await c.db.select({ value: sqlCount() }).from(table).get();
|
||||
return row?.value ?? 0;
|
||||
},
|
||||
|
||||
async getAppAuthState(c, input: { sessionId: string }) {
|
||||
const session = await c.db.select().from(authSessions).where(eq(authSessions.id, input.sessionId)).get();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
const [user, profile, currentSessionState, accounts] = await Promise.all([
|
||||
c.db.select().from(authUsers).where(eq(authUsers.id, session.userId)).get(),
|
||||
c.db.select().from(userProfiles).where(eq(userProfiles.userId, session.userId)).get(),
|
||||
c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get(),
|
||||
c.db.select().from(authAccounts).where(eq(authAccounts.userId, session.userId)).all(),
|
||||
]);
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
profile: profile ?? null,
|
||||
sessionState: currentSessionState ?? null,
|
||||
accounts,
|
||||
};
|
||||
},
|
||||
|
||||
async upsertUserProfile(
|
||||
c,
|
||||
input: {
|
||||
userId: string;
|
||||
patch: {
|
||||
githubAccountId?: string | null;
|
||||
githubLogin?: string | null;
|
||||
roleLabel?: string;
|
||||
eligibleOrganizationIdsJson?: string;
|
||||
starterRepoStatus?: string;
|
||||
starterRepoStarredAt?: number | null;
|
||||
starterRepoSkippedAt?: number | null;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(userProfiles)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
githubAccountId: input.patch.githubAccountId ?? null,
|
||||
githubLogin: input.patch.githubLogin ?? null,
|
||||
roleLabel: input.patch.roleLabel ?? "GitHub user",
|
||||
eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]",
|
||||
starterRepoStatus: input.patch.starterRepoStatus ?? "pending",
|
||||
starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null,
|
||||
starterRepoSkippedAt: input.patch.starterRepoSkippedAt ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: userProfiles.userId,
|
||||
set: {
|
||||
...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}),
|
||||
...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}),
|
||||
...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}),
|
||||
...(input.patch.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}),
|
||||
...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}),
|
||||
...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}),
|
||||
...(input.patch.starterRepoSkippedAt !== undefined ? { starterRepoSkippedAt: input.patch.starterRepoSkippedAt } : {}),
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return await c.db.select().from(userProfiles).where(eq(userProfiles.userId, input.userId)).get();
|
||||
},
|
||||
|
||||
async upsertSessionState(c, input: { sessionId: string; activeOrganizationId: string | null }) {
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(sessionState)
|
||||
.values({
|
||||
sessionId: input.sessionId,
|
||||
activeOrganizationId: input.activeOrganizationId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sessionState.sessionId,
|
||||
set: {
|
||||
activeOrganizationId: input.activeOrganizationId,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return await c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,4 +1,14 @@
|
|||
import { taskKey, taskStatusSyncKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, sandboxInstanceKey, workspaceKey } from "./keys.js";
|
||||
import {
|
||||
authUserKey,
|
||||
taskKey,
|
||||
taskStatusSyncKey,
|
||||
historyKey,
|
||||
projectBranchSyncKey,
|
||||
projectKey,
|
||||
projectPrSyncKey,
|
||||
sandboxInstanceKey,
|
||||
workspaceKey,
|
||||
} from "./keys.js";
|
||||
import type { ProviderId } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
export function actorClient(c: any) {
|
||||
|
|
@ -11,6 +21,16 @@ export async function getOrCreateWorkspace(c: any, workspaceId: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export async function getOrCreateAuthUser(c: any, userId: string) {
|
||||
return await actorClient(c).authUser.getOrCreate(authUserKey(userId), {
|
||||
createWithInput: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getAuthUser(c: any, userId: string) {
|
||||
return actorClient(c).authUser.get(authUserKey(userId));
|
||||
}
|
||||
|
||||
export async function getOrCreateProject(c: any, workspaceId: string, repoId: string, remoteUrl: string) {
|
||||
return await actorClient(c).project.getOrCreate(projectKey(workspaceId, repoId), {
|
||||
createWithInput: {
|
||||
|
|
@ -125,3 +145,7 @@ export function selfProject(c: any) {
|
|||
export function selfSandboxInstance(c: any) {
|
||||
return actorClient(c).sandboxInstance.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfAuthUser(c: any) {
|
||||
return actorClient(c).authUser.getForId(c.actorId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { authUser } from "./auth-user/index.js";
|
||||
import { setup } from "rivetkit";
|
||||
import { taskStatusSync } from "./task-status-sync/index.js";
|
||||
import { task } from "./task/index.js";
|
||||
|
|
@ -22,6 +23,7 @@ export const registry = setup({
|
|||
baseLogger: logger,
|
||||
},
|
||||
use: {
|
||||
authUser,
|
||||
workspace,
|
||||
project,
|
||||
task,
|
||||
|
|
@ -35,6 +37,7 @@ export const registry = setup({
|
|||
|
||||
export * from "./context.js";
|
||||
export * from "./events.js";
|
||||
export * from "./auth-user/index.js";
|
||||
export * from "./task-status-sync/index.js";
|
||||
export * from "./task/index.js";
|
||||
export * from "./history/index.js";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ export function workspaceKey(workspaceId: string): ActorKey {
|
|||
return ["ws", workspaceId];
|
||||
}
|
||||
|
||||
export function authUserKey(userId: string): ActorKey {
|
||||
return ["ws", "app", "user", userId];
|
||||
}
|
||||
|
||||
export function projectKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { foundryRepoClonePath } from "../../services/foundry-paths.js";
|
|||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { withRepoGitLock } from "../../services/repo-git-lock.js";
|
||||
import { branches, taskIndex, prCache, repoMeta } from "./db/schema.js";
|
||||
import { branches, taskIndex, prCache, repoActionJobs, repoMeta } from "./db/schema.js";
|
||||
import { deriveFallbackTitle } from "../../services/create-flow.js";
|
||||
import { normalizeBaseBranchName } from "../../integrations/git-spice/index.js";
|
||||
import { sortBranchesForOverview } from "./stack-model.js";
|
||||
|
|
@ -87,6 +87,7 @@ interface BranchSyncResult {
|
|||
interface RepoOverviewCommand {}
|
||||
|
||||
interface RunRepoStackActionCommand {
|
||||
jobId?: string;
|
||||
action: RepoStackAction;
|
||||
branchName?: string;
|
||||
parentBranch?: string;
|
||||
|
|
@ -133,6 +134,90 @@ async function ensureProjectSyncActors(c: any, localPath: string): Promise<void>
|
|||
c.state.syncActorsStarted = true;
|
||||
}
|
||||
|
||||
async function ensureRepoActionJobsTable(c: any): Promise<void> {
|
||||
await c.db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS repo_action_jobs (
|
||||
job_id text PRIMARY KEY NOT NULL,
|
||||
action text NOT NULL,
|
||||
branch_name text,
|
||||
parent_branch text,
|
||||
status text NOT NULL,
|
||||
message text NOT NULL,
|
||||
created_at integer NOT NULL,
|
||||
updated_at integer NOT NULL,
|
||||
completed_at integer
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async function writeRepoActionJob(
|
||||
c: any,
|
||||
input: {
|
||||
jobId: string;
|
||||
action: RepoStackAction;
|
||||
branchName: string | null;
|
||||
parentBranch: string | null;
|
||||
status: "queued" | "running" | "completed" | "error";
|
||||
message: string;
|
||||
createdAt?: number;
|
||||
completedAt?: number | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
await ensureRepoActionJobsTable(c);
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(repoActionJobs)
|
||||
.values({
|
||||
jobId: input.jobId,
|
||||
action: input.action,
|
||||
branchName: input.branchName,
|
||||
parentBranch: input.parentBranch,
|
||||
status: input.status,
|
||||
message: input.message,
|
||||
createdAt: input.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
completedAt: input.completedAt ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: repoActionJobs.jobId,
|
||||
set: {
|
||||
status: input.status,
|
||||
message: input.message,
|
||||
updatedAt: now,
|
||||
completedAt: input.completedAt ?? null,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
async function listRepoActionJobRows(c: any): Promise<
|
||||
Array<{
|
||||
jobId: string;
|
||||
action: RepoStackAction;
|
||||
branchName: string | null;
|
||||
parentBranch: string | null;
|
||||
status: "queued" | "running" | "completed" | "error";
|
||||
message: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
completedAt: number | null;
|
||||
}>
|
||||
> {
|
||||
await ensureRepoActionJobsTable(c);
|
||||
const rows = await c.db.select().from(repoActionJobs).orderBy(desc(repoActionJobs.updatedAt)).limit(20).all();
|
||||
return rows.map((row: any) => ({
|
||||
jobId: row.jobId,
|
||||
action: row.action,
|
||||
branchName: row.branchName ?? null,
|
||||
parentBranch: row.parentBranch ?? null,
|
||||
status: row.status,
|
||||
message: row.message,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
completedAt: row.completedAt ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function deleteStaleTaskIndexRow(c: any, taskId: string): Promise<void> {
|
||||
try {
|
||||
await c.db.delete(taskIndex).where(eq(taskIndex.taskId, taskId)).run();
|
||||
|
|
@ -359,8 +444,6 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
|
|||
const taskId = randomUUID();
|
||||
|
||||
if (onBranch) {
|
||||
await forceProjectSync(c, localPath);
|
||||
|
||||
const branchRow = await c.db.select({ branchName: branches.branchName }).from(branches).where(eq(branches.branchName, onBranch)).get();
|
||||
if (!branchRow) {
|
||||
throw new Error(`Branch not found in repo snapshot: ${onBranch}`);
|
||||
|
|
@ -573,14 +656,37 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
|
|||
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const at = Date.now();
|
||||
const jobId = cmd.jobId ?? randomUUID();
|
||||
const action = cmd.action;
|
||||
const branchName = cmd.branchName?.trim() || null;
|
||||
const parentBranch = cmd.parentBranch?.trim() || null;
|
||||
|
||||
await writeRepoActionJob(c, {
|
||||
jobId,
|
||||
action,
|
||||
branchName,
|
||||
parentBranch,
|
||||
status: "running",
|
||||
message: `Running ${action}`,
|
||||
createdAt: at,
|
||||
});
|
||||
|
||||
if (!(await driver.stack.available(localPath).catch(() => false))) {
|
||||
await writeRepoActionJob(c, {
|
||||
jobId,
|
||||
action,
|
||||
branchName,
|
||||
parentBranch,
|
||||
status: "error",
|
||||
message: "git-spice is not available for this repo",
|
||||
createdAt: at,
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
return {
|
||||
jobId,
|
||||
action,
|
||||
executed: false,
|
||||
status: "error",
|
||||
message: "git-spice is not available for this repo",
|
||||
at,
|
||||
};
|
||||
|
|
@ -615,48 +721,77 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
|
|||
}
|
||||
}
|
||||
|
||||
await withRepoGitLock(localPath, async () => {
|
||||
if (action === "sync_repo") {
|
||||
await driver.stack.syncRepo(localPath);
|
||||
} else if (action === "restack_repo") {
|
||||
await driver.stack.restackRepo(localPath);
|
||||
} else if (action === "restack_subtree") {
|
||||
await driver.stack.restackSubtree(localPath, branchName!);
|
||||
} else if (action === "rebase_branch") {
|
||||
await driver.stack.rebaseBranch(localPath, branchName!);
|
||||
} else if (action === "reparent_branch") {
|
||||
await driver.stack.reparentBranch(localPath, branchName!, parentBranch!);
|
||||
} else {
|
||||
throw new Error(`Unsupported repo stack action: ${action}`);
|
||||
}
|
||||
});
|
||||
|
||||
await forceProjectSync(c, localPath);
|
||||
|
||||
try {
|
||||
const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId);
|
||||
await history.append({
|
||||
kind: "repo.stack_action",
|
||||
branchName: branchName ?? null,
|
||||
payload: {
|
||||
action,
|
||||
await withRepoGitLock(localPath, async () => {
|
||||
if (action === "sync_repo") {
|
||||
await driver.stack.syncRepo(localPath);
|
||||
} else if (action === "restack_repo") {
|
||||
await driver.stack.restackRepo(localPath);
|
||||
} else if (action === "restack_subtree") {
|
||||
await driver.stack.restackSubtree(localPath, branchName!);
|
||||
} else if (action === "rebase_branch") {
|
||||
await driver.stack.rebaseBranch(localPath, branchName!);
|
||||
} else if (action === "reparent_branch") {
|
||||
await driver.stack.reparentBranch(localPath, branchName!, parentBranch!);
|
||||
} else {
|
||||
throw new Error(`Unsupported repo stack action: ${action}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId);
|
||||
await history.append({
|
||||
kind: "repo.stack_action",
|
||||
branchName: branchName ?? null,
|
||||
parentBranch: parentBranch ?? null,
|
||||
},
|
||||
payload: {
|
||||
action,
|
||||
branchName: branchName ?? null,
|
||||
parentBranch: parentBranch ?? null,
|
||||
jobId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logActorWarning("project", "failed appending repo stack history event", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
action,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
await forceProjectSync(c, localPath);
|
||||
|
||||
await writeRepoActionJob(c, {
|
||||
jobId,
|
||||
action,
|
||||
branchName,
|
||||
parentBranch,
|
||||
status: "completed",
|
||||
message: `Completed ${action}`,
|
||||
createdAt: at,
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
logActorWarning("project", "failed appending repo stack history event", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
const message = resolveErrorMessage(error);
|
||||
await writeRepoActionJob(c, {
|
||||
jobId,
|
||||
action,
|
||||
error: resolveErrorMessage(error),
|
||||
branchName,
|
||||
parentBranch,
|
||||
status: "error",
|
||||
message,
|
||||
createdAt: at,
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
jobId,
|
||||
action,
|
||||
executed: true,
|
||||
message: `stack action executed: ${action}`,
|
||||
status: "completed",
|
||||
message: `Completed ${action}`,
|
||||
at,
|
||||
};
|
||||
}
|
||||
|
|
@ -999,7 +1134,6 @@ export const projectActions = {
|
|||
async getRepoOverview(c: any, _cmd?: RepoOverviewCommand): Promise<RepoOverview> {
|
||||
const localPath = await ensureProjectReadyForRead(c);
|
||||
await ensureTaskIndexHydratedForRead(c);
|
||||
await forceProjectSync(c, localPath);
|
||||
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const now = Date.now();
|
||||
|
|
@ -1118,6 +1252,9 @@ export const projectActions = {
|
|||
};
|
||||
});
|
||||
|
||||
const latestBranchSync = await c.db.select({ updatedAt: branches.updatedAt }).from(branches).orderBy(desc(branches.updatedAt)).limit(1).get();
|
||||
const latestPrSync = await c.db.select({ updatedAt: prCache.updatedAt }).from(prCache).orderBy(desc(prCache.updatedAt)).limit(1).get();
|
||||
|
||||
return {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
|
|
@ -1125,6 +1262,11 @@ export const projectActions = {
|
|||
baseRef,
|
||||
stackAvailable,
|
||||
fetchedAt: now,
|
||||
branchSyncAt: latestBranchSync?.updatedAt ?? null,
|
||||
prSyncAt: latestPrSync?.updatedAt ?? null,
|
||||
branchSyncStatus: latestBranchSync ? "synced" : "pending",
|
||||
prSyncStatus: latestPrSync ? "synced" : "pending",
|
||||
repoActionJobs: await listRepoActionJobRows(c),
|
||||
branches: branchRows,
|
||||
};
|
||||
},
|
||||
|
|
@ -1156,12 +1298,41 @@ export const projectActions = {
|
|||
|
||||
async runRepoStackAction(c: any, cmd: RunRepoStackActionCommand): Promise<RepoStackActionResult> {
|
||||
const self = selfProject(c);
|
||||
return expectQueueResponse<RepoStackActionResult>(
|
||||
await self.send(projectWorkflowQueueName("project.command.runRepoStackAction"), cmd, {
|
||||
wait: true,
|
||||
timeout: 12 * 60_000,
|
||||
}),
|
||||
const jobId = randomUUID();
|
||||
const at = Date.now();
|
||||
const action = cmd.action;
|
||||
const branchName = cmd.branchName?.trim() || null;
|
||||
const parentBranch = cmd.parentBranch?.trim() || null;
|
||||
|
||||
await writeRepoActionJob(c, {
|
||||
jobId,
|
||||
action,
|
||||
branchName,
|
||||
parentBranch,
|
||||
status: "queued",
|
||||
message: `Queued ${action}`,
|
||||
createdAt: at,
|
||||
});
|
||||
|
||||
await self.send(
|
||||
projectWorkflowQueueName("project.command.runRepoStackAction"),
|
||||
{
|
||||
...cmd,
|
||||
jobId,
|
||||
},
|
||||
{
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
jobId,
|
||||
action,
|
||||
executed: true,
|
||||
status: "queued",
|
||||
message: `Queued ${action}`,
|
||||
at,
|
||||
};
|
||||
},
|
||||
|
||||
async applyPrSyncResult(c: any, body: PrSyncResult): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -42,3 +42,15 @@ export const taskIndex = sqliteTable("task_index", {
|
|||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const repoActionJobs = sqliteTable("repo_action_jobs", {
|
||||
jobId: text("job_id").notNull().primaryKey(),
|
||||
action: text("action").notNull(),
|
||||
branchName: text("branch_name"),
|
||||
parentBranch: text("parent_branch"),
|
||||
status: text("status").notNull(),
|
||||
message: text("message").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
completedAt: integer("completed_at"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ export const taskRuntime = sqliteTable(
|
|||
activeSwitchTarget: text("active_switch_target"),
|
||||
activeCwd: text("active_cwd"),
|
||||
statusMessage: text("status_message"),
|
||||
gitStateJson: text("git_state_json"),
|
||||
gitStateUpdatedAt: integer("git_state_updated_at"),
|
||||
provisionStage: text("provision_stage"),
|
||||
provisionStageUpdatedAt: integer("provision_stage_updated_at"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => [check("task_runtime_singleton_id_check", sql`${table.id} = 1`)],
|
||||
|
|
@ -46,8 +50,13 @@ export const taskSandboxes = sqliteTable("task_sandboxes", {
|
|||
|
||||
export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", {
|
||||
sessionId: text("session_id").notNull().primaryKey(),
|
||||
sandboxSessionId: text("sandbox_session_id"),
|
||||
sessionName: text("session_name").notNull(),
|
||||
model: text("model").notNull(),
|
||||
status: text("status").notNull().default("ready"),
|
||||
errorMessage: text("error_message"),
|
||||
transcriptJson: text("transcript_json").notNull().default("[]"),
|
||||
transcriptUpdatedAt: integer("transcript_updated_at"),
|
||||
unread: integer("unread").notNull().default(0),
|
||||
draftText: text("draft_text").notNull().default(""),
|
||||
// Structured by the workbench composer attachment payload format.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// @ts-nocheck
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename } from "node:path";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
|
|
@ -9,12 +10,26 @@ import { getCurrentRecord } from "./workflow/common.js";
|
|||
|
||||
const STATUS_SYNC_INTERVAL_MS = 1_000;
|
||||
|
||||
function emptyGitState() {
|
||||
return {
|
||||
fileChanges: [],
|
||||
diffs: {},
|
||||
fileTree: [],
|
||||
updatedAt: null as number | null,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -26,6 +41,18 @@ async function ensureWorkbenchSessionTable(c: any): Promise<void> {
|
|||
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) {
|
||||
|
|
@ -74,6 +101,40 @@ function parseDraftAttachments(value: string | null | undefined): Array<any> {
|
|||
}
|
||||
}
|
||||
|
||||
function parseTranscript(value: string | null | undefined): Array<any> {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseGitState(value: string | null | undefined): { fileChanges: Array<any>; diffs: Record<string, string>; fileTree: Array<any> } {
|
||||
if (!value) {
|
||||
return emptyGitState();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as {
|
||||
fileChanges?: unknown;
|
||||
diffs?: unknown;
|
||||
fileTree?: unknown;
|
||||
};
|
||||
return {
|
||||
fileChanges: Array.isArray(parsed.fileChanges) ? parsed.fileChanges : [],
|
||||
diffs: parsed.diffs && typeof parsed.diffs === "object" ? (parsed.diffs as Record<string, string>) : {},
|
||||
fileTree: Array.isArray(parsed.fileTree) ? parsed.fileTree : [],
|
||||
};
|
||||
} catch {
|
||||
return emptyGitState();
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean {
|
||||
if (status === "running") {
|
||||
return false;
|
||||
|
|
@ -90,7 +151,13 @@ async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }
|
|||
const mapped = rows.map((row: any) => ({
|
||||
...row,
|
||||
id: row.sessionId,
|
||||
sessionId: row.sessionId,
|
||||
sessionId: row.sandboxSessionId ?? null,
|
||||
tabId: row.sessionId,
|
||||
sandboxSessionId: row.sandboxSessionId ?? null,
|
||||
status: row.status ?? "ready",
|
||||
errorMessage: row.errorMessage ?? null,
|
||||
transcript: parseTranscript(row.transcriptJson),
|
||||
transcriptUpdatedAt: row.transcriptUpdatedAt ?? null,
|
||||
draftAttachments: parseDraftAttachments(row.draftAttachmentsJson),
|
||||
draftUpdatedAtMs: row.draftUpdatedAt ?? null,
|
||||
unread: row.unread === 1,
|
||||
|
|
@ -121,7 +188,13 @@ async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
|
|||
return {
|
||||
...row,
|
||||
id: row.sessionId,
|
||||
sessionId: row.sessionId,
|
||||
sessionId: row.sandboxSessionId ?? null,
|
||||
tabId: row.sessionId,
|
||||
sandboxSessionId: row.sandboxSessionId ?? null,
|
||||
status: row.status ?? "ready",
|
||||
errorMessage: row.errorMessage ?? null,
|
||||
transcript: parseTranscript(row.transcriptJson),
|
||||
transcriptUpdatedAt: row.transcriptUpdatedAt ?? null,
|
||||
draftAttachments: parseDraftAttachments(row.draftAttachmentsJson),
|
||||
draftUpdatedAtMs: row.draftUpdatedAt ?? null,
|
||||
unread: row.unread === 1,
|
||||
|
|
@ -133,14 +206,18 @@ async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
|
|||
async function ensureSessionMeta(
|
||||
c: any,
|
||||
params: {
|
||||
sessionId: string;
|
||||
tabId: string;
|
||||
sandboxSessionId?: string | null;
|
||||
model?: string;
|
||||
sessionName?: string;
|
||||
unread?: boolean;
|
||||
created?: boolean;
|
||||
status?: "pending_provision" | "pending_session_create" | "ready" | "error";
|
||||
errorMessage?: string | null;
|
||||
},
|
||||
): Promise<any> {
|
||||
await ensureWorkbenchSessionTable(c);
|
||||
const existing = await readSessionMeta(c, params.sessionId);
|
||||
const existing = await readSessionMeta(c, params.tabId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
|
@ -153,14 +230,19 @@ async function ensureSessionMeta(
|
|||
await c.db
|
||||
.insert(taskWorkbenchSessions)
|
||||
.values({
|
||||
sessionId: params.sessionId,
|
||||
sessionId: params.tabId,
|
||||
sandboxSessionId: params.sandboxSessionId ?? null,
|
||||
sessionName,
|
||||
model,
|
||||
status: params.status ?? "ready",
|
||||
errorMessage: params.errorMessage ?? null,
|
||||
transcriptJson: "[]",
|
||||
transcriptUpdatedAt: null,
|
||||
unread: unread ? 1 : 0,
|
||||
draftText: "",
|
||||
draftAttachmentsJson: "[]",
|
||||
draftUpdatedAt: null,
|
||||
created: 1,
|
||||
created: params.created === false ? 0 : 1,
|
||||
closed: 0,
|
||||
thinkingSinceMs: null,
|
||||
createdAt: now,
|
||||
|
|
@ -168,20 +250,40 @@ async function ensureSessionMeta(
|
|||
})
|
||||
.run();
|
||||
|
||||
return await readSessionMeta(c, params.sessionId);
|
||||
return await readSessionMeta(c, params.tabId);
|
||||
}
|
||||
|
||||
async function updateSessionMeta(c: any, sessionId: string, values: Record<string, unknown>): Promise<any> {
|
||||
await ensureSessionMeta(c, { sessionId });
|
||||
async function updateSessionMeta(c: any, tabId: string, values: Record<string, unknown>): Promise<any> {
|
||||
await ensureSessionMeta(c, { tabId });
|
||||
await c.db
|
||||
.update(taskWorkbenchSessions)
|
||||
.set({
|
||||
...values,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskWorkbenchSessions.sessionId, sessionId))
|
||||
.where(eq(taskWorkbenchSessions.sessionId, tabId))
|
||||
.run();
|
||||
return await readSessionMeta(c, sessionId);
|
||||
return await readSessionMeta(c, tabId);
|
||||
}
|
||||
|
||||
async function readSessionMetaBySandboxSessionId(c: any, sandboxSessionId: string): Promise<any | null> {
|
||||
await ensureWorkbenchSessionTable(c);
|
||||
const row = await c.db.select().from(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.sandboxSessionId, sandboxSessionId)).get();
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return await readSessionMeta(c, row.sessionId);
|
||||
}
|
||||
|
||||
async function requireReadySessionMeta(c: any, tabId: string): Promise<any> {
|
||||
const meta = await readSessionMeta(c, tabId);
|
||||
if (!meta) {
|
||||
throw new Error(`Unknown workbench tab: ${tabId}`);
|
||||
}
|
||||
if (meta.status !== "ready" || !meta.sandboxSessionId) {
|
||||
throw new Error(meta.errorMessage ?? "This workbench tab is still preparing");
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
async function notifyWorkbenchUpdated(c: any): Promise<void> {
|
||||
|
|
@ -333,17 +435,6 @@ async function collectWorkbenchGitState(c: any, record: any) {
|
|||
label: "git diff numstat",
|
||||
});
|
||||
const numstat = parseNumstat(numstatResult.result);
|
||||
const diffs: Record<string, string> = {};
|
||||
|
||||
for (const row of statusRows) {
|
||||
const diffResult = await executeInSandbox(c, {
|
||||
sandboxId: activeSandboxId,
|
||||
cwd,
|
||||
command: `if git ls-files --error-unmatch -- ${JSON.stringify(row.path)} >/dev/null 2>&1; then git diff -- ${JSON.stringify(row.path)}; else git diff --no-index -- /dev/null ${JSON.stringify(row.path)} || true; fi`,
|
||||
label: `git diff ${row.path}`,
|
||||
});
|
||||
diffs[row.path] = diffResult.result;
|
||||
}
|
||||
|
||||
const filesResult = await executeInSandbox(c, {
|
||||
sandboxId: activeSandboxId,
|
||||
|
|
@ -356,6 +447,17 @@ async function collectWorkbenchGitState(c: any, record: any) {
|
|||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const diffs: Record<string, string> = {};
|
||||
for (const row of statusRows) {
|
||||
const diffResult = await executeInSandbox(c, {
|
||||
sandboxId: activeSandboxId,
|
||||
cwd,
|
||||
command: `git diff -- ${JSON.stringify(row.path)}`,
|
||||
label: `git diff ${row.path}`,
|
||||
});
|
||||
diffs[row.path] = diffResult.exitCode === 0 ? diffResult.result : "";
|
||||
}
|
||||
|
||||
return {
|
||||
fileChanges: statusRows.map((row) => {
|
||||
const counts = numstat.get(row.path) ?? { added: 0, removed: 0 };
|
||||
|
|
@ -371,6 +473,37 @@ async function collectWorkbenchGitState(c: any, record: any) {
|
|||
};
|
||||
}
|
||||
|
||||
async function readCachedGitState(c: any): Promise<{ fileChanges: Array<any>; diffs: Record<string, string>; fileTree: Array<any>; updatedAt: number | null }> {
|
||||
await ensureTaskRuntimeCacheColumns(c);
|
||||
const row = await c.db
|
||||
.select({
|
||||
gitStateJson: taskRuntime.gitStateJson,
|
||||
gitStateUpdatedAt: taskRuntime.gitStateUpdatedAt,
|
||||
})
|
||||
.from(taskRuntime)
|
||||
.where(eq(taskRuntime.id, 1))
|
||||
.get();
|
||||
const parsed = parseGitState(row?.gitStateJson);
|
||||
return {
|
||||
...parsed,
|
||||
updatedAt: row?.gitStateUpdatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeCachedGitState(c: any, gitState: { fileChanges: Array<any>; diffs: Record<string, string>; fileTree: Array<any> }): Promise<void> {
|
||||
await ensureTaskRuntimeCacheColumns(c);
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
gitStateJson: JSON.stringify(gitState),
|
||||
gitStateUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(taskRuntime.id, 1))
|
||||
.run();
|
||||
}
|
||||
|
||||
async function readSessionTranscript(c: any, record: any, sessionId: string) {
|
||||
const sandboxId = record.activeSandboxId ?? record.sandboxes?.[0]?.sandboxId ?? null;
|
||||
if (!sandboxId) {
|
||||
|
|
@ -380,7 +513,7 @@ async function readSessionTranscript(c: any, record: any, sessionId: string) {
|
|||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, sandboxId);
|
||||
const page = await sandbox.listSessionEvents({
|
||||
sessionId,
|
||||
limit: 500,
|
||||
limit: 100,
|
||||
});
|
||||
return page.items.map((event: any) => ({
|
||||
id: event.id,
|
||||
|
|
@ -393,14 +526,50 @@ async function readSessionTranscript(c: any, record: any, sessionId: string) {
|
|||
}));
|
||||
}
|
||||
|
||||
async function activeSessionStatus(c: any, record: any, sessionId: string) {
|
||||
if (record.activeSessionId !== sessionId || !record.activeSandboxId) {
|
||||
async function writeSessionTranscript(c: any, tabId: string, transcript: Array<any>): Promise<void> {
|
||||
await updateSessionMeta(c, tabId, {
|
||||
transcriptJson: JSON.stringify(transcript),
|
||||
transcriptUpdatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function enqueueWorkbenchRefresh(
|
||||
c: any,
|
||||
command: "task.command.workbench.refresh_derived" | "task.command.workbench.refresh_session_transcript",
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(command, body, { wait: false });
|
||||
}
|
||||
|
||||
async function maybeScheduleWorkbenchRefreshes(c: any, record: any, sessions: Array<any>): Promise<void> {
|
||||
const gitState = await readCachedGitState(c);
|
||||
if (record.activeSandboxId && !gitState.updatedAt) {
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
if (session.closed || session.status !== "ready" || !session.sandboxSessionId || session.transcriptUpdatedAt) {
|
||||
continue;
|
||||
}
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
|
||||
sessionId: session.sandboxSessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function activeSessionStatus(record: any, sessionId: string) {
|
||||
if (record.activeSessionId !== sessionId) {
|
||||
return "idle";
|
||||
}
|
||||
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
const status = await sandbox.sessionStatus({ sessionId });
|
||||
return status.status;
|
||||
if (record.status === "running") {
|
||||
return "running";
|
||||
}
|
||||
if (record.status === "error") {
|
||||
return "error";
|
||||
}
|
||||
return "idle";
|
||||
}
|
||||
|
||||
async function readPullRequestSummary(c: any, branchName: string | null) {
|
||||
|
|
@ -417,12 +586,15 @@ async function readPullRequestSummary(c: any, branchName: string | null) {
|
|||
}
|
||||
|
||||
export async function ensureWorkbenchSeeded(c: any): Promise<any> {
|
||||
await ensureTaskRuntimeCacheColumns(c);
|
||||
const record = await getCurrentRecord({ db: c.db, state: c.state });
|
||||
if (record.activeSessionId) {
|
||||
await ensureSessionMeta(c, {
|
||||
sessionId: record.activeSessionId,
|
||||
tabId: record.activeSessionId,
|
||||
sandboxSessionId: record.activeSessionId,
|
||||
model: defaultModelForAgent(record.agentType),
|
||||
sessionName: "Session 1",
|
||||
status: "ready",
|
||||
});
|
||||
}
|
||||
return record;
|
||||
|
|
@ -430,35 +602,38 @@ export async function ensureWorkbenchSeeded(c: any): Promise<any> {
|
|||
|
||||
export async function getWorkbenchTask(c: any): Promise<any> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const gitState = await collectWorkbenchGitState(c, record);
|
||||
const gitState = await readCachedGitState(c);
|
||||
const sessions = await listSessionMetaRows(c);
|
||||
await maybeScheduleWorkbenchRefreshes(c, record, sessions);
|
||||
const tabs = [];
|
||||
|
||||
for (const meta of sessions) {
|
||||
const status = await activeSessionStatus(c, record, meta.sessionId);
|
||||
const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null);
|
||||
const sessionStatus =
|
||||
meta.status === "ready" && derivedSandboxSessionId ? activeSessionStatus(record, derivedSandboxSessionId) : meta.status === "error" ? "error" : "idle";
|
||||
let thinkingSinceMs = meta.thinkingSinceMs ?? null;
|
||||
let unread = Boolean(meta.unread);
|
||||
if (thinkingSinceMs && status !== "running") {
|
||||
if (thinkingSinceMs && sessionStatus !== "running") {
|
||||
thinkingSinceMs = null;
|
||||
unread = true;
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
id: meta.id,
|
||||
sessionId: meta.sessionId,
|
||||
sessionId: derivedSandboxSessionId,
|
||||
sessionName: meta.sessionName,
|
||||
agent: agentKindForModel(meta.model),
|
||||
model: meta.model,
|
||||
status,
|
||||
thinkingSinceMs: status === "running" ? thinkingSinceMs : null,
|
||||
status: sessionStatus,
|
||||
thinkingSinceMs: sessionStatus === "running" ? thinkingSinceMs : null,
|
||||
unread,
|
||||
created: Boolean(meta.created),
|
||||
created: Boolean(meta.created || derivedSandboxSessionId),
|
||||
draft: {
|
||||
text: meta.draftText ?? "",
|
||||
attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [],
|
||||
updatedAtMs: meta.draftUpdatedAtMs ?? null,
|
||||
},
|
||||
transcript: await readSessionTranscript(c, record, meta.sessionId),
|
||||
transcript: meta.transcript ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -479,6 +654,25 @@ export async function getWorkbenchTask(c: any): Promise<any> {
|
|||
};
|
||||
}
|
||||
|
||||
export async function refreshWorkbenchDerivedState(c: any): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const gitState = await collectWorkbenchGitState(c, record);
|
||||
await writeCachedGitState(c, gitState);
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function refreshWorkbenchSessionTranscript(c: any, sessionId: string): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await readSessionMeta(c, sessionId));
|
||||
if (!meta?.sandboxSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transcript = await readSessionTranscript(c, record, meta.sandboxSessionId);
|
||||
await writeSessionTranscript(c, meta.tabId, transcript);
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function renameWorkbenchTask(c: any, value: string): Promise<void> {
|
||||
const nextTitle = value.trim();
|
||||
if (!nextTitle) {
|
||||
|
|
@ -549,51 +743,157 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
|
|||
}
|
||||
|
||||
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
|
||||
let record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId();
|
||||
await selfTask(c).provision({ providerId });
|
||||
record = await ensureWorkbenchSeeded(c);
|
||||
}
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
|
||||
if (record.activeSessionId) {
|
||||
const existingSessions = await listSessionMetaRows(c);
|
||||
if (existingSessions.length === 0) {
|
||||
await ensureSessionMeta(c, {
|
||||
sessionId: record.activeSessionId,
|
||||
tabId: record.activeSessionId,
|
||||
sandboxSessionId: record.activeSessionId,
|
||||
model: model ?? defaultModelForAgent(record.agentType),
|
||||
sessionName: "Session 1",
|
||||
status: "ready",
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
return { tabId: record.activeSessionId };
|
||||
}
|
||||
}
|
||||
|
||||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot create session without an active sandbox");
|
||||
const tabId = `tab-${randomUUID()}`;
|
||||
await ensureSessionMeta(c, {
|
||||
tabId,
|
||||
model: model ?? defaultModelForAgent(record.agentType),
|
||||
status: record.activeSandboxId ? "pending_session_create" : "pending_provision",
|
||||
created: false,
|
||||
});
|
||||
|
||||
const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId();
|
||||
const self = selfTask(c);
|
||||
if (!record.activeSandboxId && !String(record.status ?? "").startsWith("init_")) {
|
||||
await self.send("task.command.provision", { providerId }, { wait: false });
|
||||
}
|
||||
await self.send(
|
||||
"task.command.workbench.ensure_session",
|
||||
{ tabId, ...(model ? { model } : {}) },
|
||||
{
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
await notifyWorkbenchUpdated(c);
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
export async function ensureWorkbenchSession(c: any, tabId: string, model?: string): Promise<void> {
|
||||
const meta = await readSessionMeta(c, tabId);
|
||||
if (!meta || meta.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
await updateSessionMeta(c, tabId, {
|
||||
status: "pending_provision",
|
||||
errorMessage: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!meta.sandboxSessionId && record.activeSessionId && meta.status === "pending_provision") {
|
||||
const existingTabForActiveSession = await readSessionMetaBySandboxSessionId(c, record.activeSessionId);
|
||||
if (existingTabForActiveSession && existingTabForActiveSession.tabId !== tabId) {
|
||||
await updateSessionMeta(c, existingTabForActiveSession.tabId, {
|
||||
closed: 1,
|
||||
});
|
||||
}
|
||||
await updateSessionMeta(c, tabId, {
|
||||
sandboxSessionId: record.activeSessionId,
|
||||
status: "ready",
|
||||
errorMessage: null,
|
||||
created: 1,
|
||||
});
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
|
||||
sessionId: record.activeSessionId,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.sandboxSessionId) {
|
||||
await updateSessionMeta(c, tabId, {
|
||||
status: "ready",
|
||||
errorMessage: null,
|
||||
});
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
|
||||
sessionId: meta.sandboxSessionId,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
|
||||
if (!cwd) {
|
||||
throw new Error("cannot create session without a sandbox cwd");
|
||||
await updateSessionMeta(c, tabId, {
|
||||
status: "error",
|
||||
errorMessage: "cannot create session without a sandbox cwd",
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
return;
|
||||
}
|
||||
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
const created = await sandbox.createSession({
|
||||
prompt: "",
|
||||
cwd,
|
||||
agent: agentTypeForModel(model ?? defaultModelForAgent(record.agentType)),
|
||||
await updateSessionMeta(c, tabId, {
|
||||
status: "pending_session_create",
|
||||
errorMessage: null,
|
||||
});
|
||||
if (!created.id) {
|
||||
throw new Error(created.error ?? "sandbox-agent session creation failed");
|
||||
|
||||
try {
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
const created = await sandbox.createSession({
|
||||
prompt: "",
|
||||
cwd,
|
||||
agent: agentTypeForModel(model ?? meta.model ?? defaultModelForAgent(record.agentType)),
|
||||
});
|
||||
if (!created.id) {
|
||||
throw new Error(created.error ?? "sandbox-agent session creation failed");
|
||||
}
|
||||
|
||||
await updateSessionMeta(c, tabId, {
|
||||
sandboxSessionId: created.id,
|
||||
status: "ready",
|
||||
errorMessage: null,
|
||||
});
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
|
||||
sessionId: created.id,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateSessionMeta(c, tabId, {
|
||||
status: "error",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
await ensureSessionMeta(c, {
|
||||
sessionId: created.id,
|
||||
model: model ?? defaultModelForAgent(record.agentType),
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
return { tabId: created.id };
|
||||
}
|
||||
|
||||
export async function enqueuePendingWorkbenchSessions(c: any): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
const pending = (await listSessionMetaRows(c, { includeClosed: true })).filter(
|
||||
(row) => row.closed !== true && row.status !== "ready" && row.status !== "error",
|
||||
);
|
||||
|
||||
for (const row of pending) {
|
||||
await self.send(
|
||||
"task.command.workbench.ensure_session",
|
||||
{
|
||||
tabId: row.tabId,
|
||||
model: row.model,
|
||||
},
|
||||
{
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameWorkbenchSession(c: any, sessionId: string, title: string): Promise<void> {
|
||||
|
|
@ -636,7 +936,7 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
|
|||
throw new Error("cannot send message without an active sandbox");
|
||||
}
|
||||
|
||||
await ensureSessionMeta(c, { sessionId });
|
||||
const meta = await requireReadySessionMeta(c, sessionId);
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)]
|
||||
.filter(Boolean)
|
||||
|
|
@ -646,7 +946,7 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
|
|||
}
|
||||
|
||||
await sandbox.sendPrompt({
|
||||
sessionId,
|
||||
sessionId: meta.sandboxSessionId,
|
||||
prompt,
|
||||
notification: true,
|
||||
});
|
||||
|
|
@ -663,24 +963,27 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
|
|||
await c.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
activeSessionId: sessionId,
|
||||
activeSessionId: meta.sandboxSessionId,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, 1))
|
||||
.run();
|
||||
|
||||
const sync = await getOrCreateTaskStatusSync(c, c.state.workspaceId, c.state.repoId, c.state.taskId, record.activeSandboxId, sessionId, {
|
||||
const sync = await getOrCreateTaskStatusSync(c, c.state.workspaceId, c.state.repoId, c.state.taskId, record.activeSandboxId, meta.sandboxSessionId, {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
taskId: c.state.taskId,
|
||||
providerId: c.state.providerId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId,
|
||||
sessionId: meta.sandboxSessionId,
|
||||
intervalMs: STATUS_SYNC_INTERVAL_MS,
|
||||
});
|
||||
await sync.setIntervalMs({ intervalMs: STATUS_SYNC_INTERVAL_MS });
|
||||
await sync.start();
|
||||
await sync.force();
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
|
||||
sessionId: meta.sandboxSessionId,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
|
|
@ -689,8 +992,9 @@ export async function stopWorkbenchSession(c: any, sessionId: string): Promise<v
|
|||
if (!record.activeSandboxId) {
|
||||
return;
|
||||
}
|
||||
const meta = await requireReadySessionMeta(c, sessionId);
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
await sandbox.cancelSession({ sessionId });
|
||||
await sandbox.cancelSession({ sessionId: meta.sandboxSessionId });
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
thinkingSinceMs: null,
|
||||
});
|
||||
|
|
@ -699,10 +1003,10 @@ export async function stopWorkbenchSession(c: any, sessionId: string): Promise<v
|
|||
|
||||
export async function syncWorkbenchSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const meta = await ensureSessionMeta(c, { sessionId });
|
||||
const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await ensureSessionMeta(c, { tabId: sessionId, sandboxSessionId: sessionId }));
|
||||
let changed = false;
|
||||
|
||||
if (record.activeSessionId === sessionId) {
|
||||
if (record.activeSessionId === sessionId || record.activeSessionId === meta.sandboxSessionId) {
|
||||
const mappedStatus = status === "running" ? "running" : status === "error" ? "error" : "idle";
|
||||
if (record.status !== mappedStatus) {
|
||||
await c.db
|
||||
|
|
@ -753,27 +1057,36 @@ export async function syncWorkbenchSessionStatus(c: any, sessionId: string, stat
|
|||
}
|
||||
|
||||
if (changed) {
|
||||
if (status !== "running") {
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
|
||||
sessionId,
|
||||
});
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
|
||||
}
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeWorkbenchSession(c: any, sessionId: string): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
return;
|
||||
}
|
||||
const sessions = await listSessionMetaRows(c);
|
||||
if (sessions.filter((candidate) => candidate.closed !== true).length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
await sandbox.destroySession({ sessionId });
|
||||
const meta = await readSessionMeta(c, sessionId);
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
if (record.activeSandboxId && meta.sandboxSessionId) {
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
await sandbox.destroySession({ sessionId: meta.sandboxSessionId });
|
||||
}
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
closed: 1,
|
||||
thinkingSinceMs: null,
|
||||
});
|
||||
if (record.activeSessionId === sessionId) {
|
||||
if (record.activeSessionId === sessionId || record.activeSessionId === meta.sandboxSessionId) {
|
||||
await c.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
|
|
@ -792,7 +1105,7 @@ export async function markWorkbenchUnread(c: any): Promise<void> {
|
|||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
await updateSessionMeta(c, latest.sessionId, {
|
||||
await updateSessionMeta(c, latest.tabId, {
|
||||
unread: 1,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
|
|
@ -838,5 +1151,6 @@ export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
|
|||
if (result.exitCode !== 0) {
|
||||
throw new Error(`file revert failed (${result.exitCode}): ${result.result}`);
|
||||
}
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
initCompleteActivity,
|
||||
initCreateSandboxActivity,
|
||||
initCreateSessionActivity,
|
||||
initEnqueueProvisionActivity,
|
||||
initEnsureAgentActivity,
|
||||
initEnsureNameActivity,
|
||||
initExposeSandboxActivity,
|
||||
|
|
@ -32,6 +33,9 @@ import {
|
|||
changeWorkbenchModel,
|
||||
closeWorkbenchSession,
|
||||
createWorkbenchSession,
|
||||
ensureWorkbenchSession,
|
||||
refreshWorkbenchDerivedState,
|
||||
refreshWorkbenchSessionTranscript,
|
||||
markWorkbenchUnread,
|
||||
publishWorkbenchPr,
|
||||
renameWorkbenchBranch,
|
||||
|
|
@ -56,7 +60,7 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
|||
const body = msg.body;
|
||||
|
||||
await loopCtx.step("init-bootstrap-db", async () => initBootstrapDbActivity(loopCtx, body));
|
||||
await loopCtx.removed("init-enqueue-provision", "step");
|
||||
await loopCtx.step("init-enqueue-provision", async () => initEnqueueProvisionActivity(loopCtx, body));
|
||||
await loopCtx.removed("init-dispatch-provision-v2", "step");
|
||||
const currentRecord = await loopCtx.step("init-read-current-record", async () => getCurrentRecord(loopCtx));
|
||||
|
||||
|
|
@ -166,12 +170,21 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
|||
"task.command.workbench.create_session": async (loopCtx, msg) => {
|
||||
const created = await loopCtx.step({
|
||||
name: "workbench-create-session",
|
||||
timeout: 5 * 60_000,
|
||||
timeout: 30_000,
|
||||
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
|
||||
});
|
||||
await msg.complete(created);
|
||||
},
|
||||
|
||||
"task.command.workbench.ensure_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-ensure-session",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => ensureWorkbenchSession(loopCtx, msg.body.tabId, msg.body?.model),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.rename_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-rename-session", async () => renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title));
|
||||
await msg.complete({ ok: true });
|
||||
|
|
@ -215,6 +228,24 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
|||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.refresh_derived": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-refresh-derived",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => refreshWorkbenchDerivedState(loopCtx),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.refresh_session_transcript": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-refresh-session-transcript",
|
||||
timeout: 60_000,
|
||||
run: async () => refreshWorkbenchSessionTranscript(loopCtx, msg.body.sessionId),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.close_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-close-session",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
|||
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, buildAgentPrompt, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js";
|
||||
import { taskWorkflowQueueName } from "./queue.js";
|
||||
import { enqueuePendingWorkbenchSessions } from "../workbench.js";
|
||||
|
||||
const DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS = 180_000;
|
||||
|
||||
|
|
@ -96,6 +97,10 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: initialStatusMessage,
|
||||
gitStateJson: null,
|
||||
gitStateUpdatedAt: null,
|
||||
provisionStage: "queued",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
|
|
@ -106,6 +111,8 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: initialStatusMessage,
|
||||
provisionStage: "queued",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
|
|
@ -118,19 +125,29 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
|
||||
export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise<void> {
|
||||
await setTaskState(loopCtx, "init_enqueue_provision", "provision queued");
|
||||
const self = selfTask(loopCtx);
|
||||
void self
|
||||
.send(taskWorkflowQueueName("task.command.provision"), body, {
|
||||
wait: false,
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
provisionStage: "queued",
|
||||
provisionStageUpdatedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logActorWarning("task.init", "background provision command failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
const self = selfTask(loopCtx);
|
||||
try {
|
||||
await self.send(taskWorkflowQueueName("task.command.provision"), body, {
|
||||
wait: false,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logActorWarning("task.init", "background provision command failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
||||
|
|
@ -197,6 +214,8 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
|||
.update(taskRuntime)
|
||||
.set({
|
||||
statusMessage: "provisioning",
|
||||
provisionStage: "repo_prepared",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
|
|
@ -222,6 +241,15 @@ export async function initAssertNameActivity(loopCtx: any): Promise<void> {
|
|||
|
||||
export async function initCreateSandboxActivity(loopCtx: any, body: any): Promise<any> {
|
||||
await setTaskState(loopCtx, "init_create_sandbox", "creating sandbox");
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
provisionStage: "sandbox_allocated",
|
||||
provisionStageUpdatedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const provider = providers.get(providerId);
|
||||
|
|
@ -307,6 +335,15 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
|
|||
|
||||
export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox: any): Promise<any> {
|
||||
await setTaskState(loopCtx, "init_ensure_agent", "ensuring sandbox agent");
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
provisionStage: "agent_installing",
|
||||
provisionStageUpdatedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const provider = providers.get(providerId);
|
||||
|
|
@ -318,6 +355,15 @@ export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox:
|
|||
|
||||
export async function initStartSandboxInstanceActivity(loopCtx: any, body: any, sandbox: any, agent: any): Promise<any> {
|
||||
await setTaskState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime");
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
provisionStage: "agent_starting",
|
||||
provisionStageUpdatedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
try {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sandboxInstance = await getOrCreateSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId, {
|
||||
|
|
@ -350,6 +396,15 @@ export async function initStartSandboxInstanceActivity(loopCtx: any, body: any,
|
|||
|
||||
export async function initCreateSessionActivity(loopCtx: any, body: any, sandbox: any, sandboxInstanceReady: any): Promise<any> {
|
||||
await setTaskState(loopCtx, "init_create_session", "creating agent session");
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
provisionStage: "session_creating",
|
||||
provisionStageUpdatedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
if (!sandboxInstanceReady.ok) {
|
||||
return {
|
||||
id: null,
|
||||
|
|
@ -481,6 +536,8 @@ export async function initWriteDbActivity(
|
|||
activeSwitchTarget: sandbox.switchTarget,
|
||||
activeCwd,
|
||||
statusMessage,
|
||||
provisionStage: sessionHealthy ? "ready" : "error",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
|
|
@ -491,6 +548,8 @@ export async function initWriteDbActivity(
|
|||
activeSwitchTarget: sandbox.switchTarget,
|
||||
activeCwd,
|
||||
statusMessage,
|
||||
provisionStage: sessionHealthy ? "ready" : "error",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
|
|
@ -535,6 +594,12 @@ export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any
|
|||
});
|
||||
|
||||
loopCtx.state.initialized = true;
|
||||
await enqueuePendingWorkbenchSessions(loopCtx);
|
||||
const self = selfTask(loopCtx);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.refresh_derived"), {}, { wait: false });
|
||||
if (sessionId) {
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.refresh_session_transcript"), { sessionId }, { wait: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -591,6 +656,8 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
|
|||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: detail,
|
||||
provisionStage: "error",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
|
|
@ -601,6 +668,8 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
|
|||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: detail,
|
||||
provisionStage: "error",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export const TASK_QUEUE_NAMES = [
|
|||
"task.command.workbench.rename_task",
|
||||
"task.command.workbench.rename_branch",
|
||||
"task.command.workbench.create_session",
|
||||
"task.command.workbench.ensure_session",
|
||||
"task.command.workbench.rename_session",
|
||||
"task.command.workbench.set_session_unread",
|
||||
"task.command.workbench.update_draft",
|
||||
|
|
@ -20,6 +21,8 @@ export const TASK_QUEUE_NAMES = [
|
|||
"task.command.workbench.send_message",
|
||||
"task.command.workbench.stop_session",
|
||||
"task.command.workbench.sync_session_status",
|
||||
"task.command.workbench.refresh_derived",
|
||||
"task.command.workbench.refresh_session_transcript",
|
||||
"task.command.workbench.close_session",
|
||||
"task.command.workbench.publish_pr",
|
||||
"task.command.workbench.revert_file",
|
||||
|
|
|
|||
|
|
@ -306,9 +306,6 @@ async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskR
|
|||
})
|
||||
.run();
|
||||
|
||||
const task = getTask(c, c.state.workspaceId, repoId, created.taskId);
|
||||
await task.provision({ providerId });
|
||||
|
||||
await workspaceActions.notifyWorkbenchUpdated(c);
|
||||
return created;
|
||||
}
|
||||
|
|
@ -483,11 +480,8 @@ export const workspaceActions = {
|
|||
...(input.branch ? { explicitBranchName: input.branch } : {}),
|
||||
...(input.model ? { agentType: agentTypeForModel(input.model) } : {}),
|
||||
});
|
||||
const task = await requireWorkbenchTask(c, created.taskId);
|
||||
const snapshot = await task.getWorkbench({});
|
||||
return {
|
||||
taskId: created.taskId,
|
||||
tabId: snapshot.tabs[0]?.id,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
// @ts-nocheck
|
||||
import { desc, eq } 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 { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
FoundryAppSnapshot,
|
||||
|
|
@ -13,18 +12,93 @@ import type {
|
|||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getOrCreateWorkspace, selfWorkspace } from "../handles.js";
|
||||
import { GitHubAppError } from "../../services/app-github.js";
|
||||
import { getBetterAuthService } from "../../services/better-auth.js";
|
||||
import { repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js";
|
||||
import { logger } from "../../logging.js";
|
||||
import { appSessions, invoices, organizationMembers, organizationProfile, repos, seatAssignments, stripeLookup } from "./db/schema.js";
|
||||
import {
|
||||
authAccountIndex,
|
||||
authEmailIndex,
|
||||
authSessionIndex,
|
||||
authVerification,
|
||||
invoices,
|
||||
organizationMembers,
|
||||
organizationProfile,
|
||||
repos,
|
||||
seatAssignments,
|
||||
stripeLookup,
|
||||
} from "./db/schema.js";
|
||||
|
||||
export const APP_SHELL_WORKSPACE_ID = "app";
|
||||
|
||||
// ── Better Auth adapter where-clause helpers ──
|
||||
// These convert the adapter's `{ field, value, operator }` clause arrays into
|
||||
// Drizzle predicates for workspace-level auth index / verification tables.
|
||||
|
||||
function workspaceAuthColumn(table: any, field: string): any {
|
||||
const column = table[field];
|
||||
if (!column) {
|
||||
throw new Error(`Unknown auth table field: ${field}`);
|
||||
}
|
||||
return column;
|
||||
}
|
||||
|
||||
function normalizeAuthValue(value: unknown): unknown {
|
||||
if (value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => normalizeAuthValue(entry));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function workspaceAuthClause(table: any, clause: { field: string; value: unknown; operator?: string }): any {
|
||||
const column = workspaceAuthColumn(table, clause.field);
|
||||
const value = normalizeAuthValue(clause.value);
|
||||
switch (clause.operator) {
|
||||
case "ne":
|
||||
return value === null ? isNotNull(column) : ne(column, value as any);
|
||||
case "lt":
|
||||
return lt(column, value as any);
|
||||
case "lte":
|
||||
return lte(column, value as any);
|
||||
case "gt":
|
||||
return gt(column, value as any);
|
||||
case "gte":
|
||||
return gte(column, value as any);
|
||||
case "in":
|
||||
return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
|
||||
case "not_in":
|
||||
return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
|
||||
case "contains":
|
||||
return like(column, `%${String(value ?? "")}%`);
|
||||
case "starts_with":
|
||||
return like(column, `${String(value ?? "")}%`);
|
||||
case "ends_with":
|
||||
return like(column, `%${String(value ?? "")}`);
|
||||
case "eq":
|
||||
default:
|
||||
return value === null ? isNull(column) : eq(column, value as any);
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceAuthWhere(table: any, clauses: any[] | undefined): any {
|
||||
if (!clauses || clauses.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
let expr = workspaceAuthClause(table, clauses[0]);
|
||||
for (const clause of clauses.slice(1)) {
|
||||
const next = workspaceAuthClause(table, clause);
|
||||
expr = clause.connector === "OR" ? or(expr, next) : and(expr, next);
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
const githubWebhookLogger = logger.child({
|
||||
scope: "github-webhook",
|
||||
});
|
||||
|
||||
const PROFILE_ROW_ID = "profile";
|
||||
const OAUTH_TTL_MS = 10 * 60_000;
|
||||
|
||||
function roundDurationMs(start: number): number {
|
||||
return Math.round((performance.now() - start) * 100) / 100;
|
||||
|
|
@ -58,13 +132,6 @@ function organizationWorkspaceId(kind: FoundryOrganization["kind"], login: strin
|
|||
return kind === "personal" ? personalWorkspaceId(login) : slugify(login);
|
||||
}
|
||||
|
||||
function splitScopes(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
function hasRepoScope(scopes: string[]): boolean {
|
||||
return scopes.some((scope) => scope === "repo" || scope.startsWith("repo:"));
|
||||
}
|
||||
|
|
@ -85,21 +152,6 @@ function encodeEligibleOrganizationIds(value: string[]): string {
|
|||
return JSON.stringify([...new Set(value)]);
|
||||
}
|
||||
|
||||
function encodeOauthState(payload: { sessionId: string; nonce: string }): string {
|
||||
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodeOauthState(value: string): { sessionId: string; nonce: string } {
|
||||
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Record<string, unknown>;
|
||||
if (typeof parsed.sessionId !== "string" || typeof parsed.nonce !== "string") {
|
||||
throw new Error("GitHub OAuth state is malformed");
|
||||
}
|
||||
return {
|
||||
sessionId: parsed.sessionId,
|
||||
nonce: parsed.nonce,
|
||||
};
|
||||
}
|
||||
|
||||
function seatsIncludedForPlan(planId: FoundryBillingPlanId): number {
|
||||
switch (planId) {
|
||||
case "free":
|
||||
|
|
@ -161,70 +213,6 @@ function stripeWebhookSubscription(event: any) {
|
|||
};
|
||||
}
|
||||
|
||||
async function getAppSessionRow(c: any, sessionId: string) {
|
||||
assertAppWorkspace(c);
|
||||
return await c.db.select().from(appSessions).where(eq(appSessions.id, sessionId)).get();
|
||||
}
|
||||
|
||||
async function requireAppSessionRow(c: any, sessionId: string) {
|
||||
const row = await getAppSessionRow(c, sessionId);
|
||||
if (!row) {
|
||||
throw new Error(`Unknown app session: ${sessionId}`);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async function ensureAppSession(c: any, requestedSessionId?: string | null): Promise<string> {
|
||||
assertAppWorkspace(c);
|
||||
const requested = typeof requestedSessionId === "string" && requestedSessionId.trim().length > 0 ? requestedSessionId.trim() : null;
|
||||
|
||||
if (requested) {
|
||||
const existing = await getAppSessionRow(c, requested);
|
||||
if (existing) {
|
||||
return requested;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = requested ?? randomUUID();
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(appSessions)
|
||||
.values({
|
||||
id: sessionId,
|
||||
currentUserId: null,
|
||||
currentUserName: null,
|
||||
currentUserEmail: null,
|
||||
currentUserGithubLogin: null,
|
||||
currentUserRoleLabel: null,
|
||||
eligibleOrganizationIdsJson: "[]",
|
||||
activeOrganizationId: null,
|
||||
githubAccessToken: null,
|
||||
githubScope: "",
|
||||
starterRepoStatus: "pending",
|
||||
starterRepoStarredAt: null,
|
||||
starterRepoSkippedAt: null,
|
||||
oauthState: null,
|
||||
oauthStateExpiresAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function updateAppSession(c: any, sessionId: string, patch: Record<string, unknown>): Promise<void> {
|
||||
assertAppWorkspace(c);
|
||||
await c.db
|
||||
.update(appSessions)
|
||||
.set({
|
||||
...patch,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(appSessions.id, sessionId))
|
||||
.run();
|
||||
}
|
||||
|
||||
async function getOrganizationState(workspace: any) {
|
||||
return await workspace.getOrganizationShellState({});
|
||||
}
|
||||
|
|
@ -232,8 +220,27 @@ async function getOrganizationState(workspace: any) {
|
|||
async function buildAppSnapshot(c: any, sessionId: string): Promise<FoundryAppSnapshot> {
|
||||
assertAppWorkspace(c);
|
||||
const startedAt = performance.now();
|
||||
const session = await requireAppSessionRow(c, sessionId);
|
||||
const eligibleOrganizationIds = parseEligibleOrganizationIds(session.eligibleOrganizationIdsJson);
|
||||
const auth = getBetterAuthService();
|
||||
let authState = await auth.getAuthState(sessionId);
|
||||
// Inline fallback: if the user is signed in but has no eligible organizations yet
|
||||
// (e.g. first load after OAuth callback), sync GitHub orgs before building the snapshot.
|
||||
if (authState?.user && parseEligibleOrganizationIds(authState.profile?.eligibleOrganizationIdsJson ?? "[]").length === 0) {
|
||||
const token = await auth.getAccessTokenForSession(sessionId);
|
||||
if (token?.accessToken) {
|
||||
logger.info({ sessionId }, "build_app_snapshot_sync_orgs");
|
||||
await syncGithubOrganizations(c, { sessionId, accessToken: token.accessToken });
|
||||
authState = await auth.getAuthState(sessionId);
|
||||
} else {
|
||||
logger.warn({ sessionId }, "build_app_snapshot_no_access_token");
|
||||
}
|
||||
}
|
||||
|
||||
const session = authState?.session ?? null;
|
||||
const user = authState?.user ?? null;
|
||||
const profile = authState?.profile ?? null;
|
||||
const currentSessionState = authState?.sessionState ?? null;
|
||||
const githubAccount = authState?.accounts?.find((account: any) => account.providerId === "github") ?? null;
|
||||
const eligibleOrganizationIds = parseEligibleOrganizationIds(profile?.eligibleOrganizationIdsJson ?? "[]");
|
||||
|
||||
logger.info(
|
||||
{
|
||||
|
|
@ -293,25 +300,27 @@ async function buildAppSnapshot(c: any, sessionId: string): Promise<FoundryAppSn
|
|||
)
|
||||
).filter((organization): organization is FoundryOrganization => organization !== null);
|
||||
|
||||
const currentUser: FoundryUser | null = session.currentUserId
|
||||
const currentUser: FoundryUser | null = user
|
||||
? {
|
||||
id: session.currentUserId,
|
||||
name: session.currentUserName ?? session.currentUserGithubLogin ?? "GitHub user",
|
||||
email: session.currentUserEmail ?? "",
|
||||
githubLogin: session.currentUserGithubLogin ?? "",
|
||||
roleLabel: session.currentUserRoleLabel ?? "GitHub user",
|
||||
eligibleOrganizationIds: organizations.map((organization) => organization.id),
|
||||
id: profile?.githubAccountId ?? githubAccount?.accountId ?? user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
githubLogin: profile?.githubLogin ?? "",
|
||||
roleLabel: profile?.roleLabel ?? "GitHub user",
|
||||
eligibleOrganizationIds,
|
||||
}
|
||||
: null;
|
||||
|
||||
const activeOrganizationId =
|
||||
currentUser && session.activeOrganizationId && organizations.some((organization) => organization.id === session.activeOrganizationId)
|
||||
? session.activeOrganizationId
|
||||
currentUser &&
|
||||
currentSessionState?.activeOrganizationId &&
|
||||
organizations.some((organization) => organization.id === currentSessionState.activeOrganizationId)
|
||||
? currentSessionState.activeOrganizationId
|
||||
: currentUser && organizations.length === 1
|
||||
? (organizations[0]?.id ?? null)
|
||||
: null;
|
||||
|
||||
const snapshot = {
|
||||
const snapshot: FoundryAppSnapshot = {
|
||||
auth: {
|
||||
status: currentUser ? "signed_in" : "signed_out",
|
||||
currentUserId: currentUser?.id ?? null,
|
||||
|
|
@ -321,9 +330,9 @@ async function buildAppSnapshot(c: any, sessionId: string): Promise<FoundryAppSn
|
|||
starterRepo: {
|
||||
repoFullName: "rivet-dev/sandbox-agent",
|
||||
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
|
||||
status: session.starterRepoStatus ?? "pending",
|
||||
starredAt: session.starterRepoStarredAt ?? null,
|
||||
skippedAt: session.starterRepoSkippedAt ?? null,
|
||||
status: profile?.starterRepoStatus ?? "pending",
|
||||
starredAt: profile?.starterRepoStarredAt ?? null,
|
||||
skippedAt: profile?.starterRepoSkippedAt ?? null,
|
||||
},
|
||||
},
|
||||
users: currentUser ? [currentUser] : [],
|
||||
|
|
@ -345,11 +354,30 @@ async function buildAppSnapshot(c: any, sessionId: string): Promise<FoundryAppSn
|
|||
}
|
||||
|
||||
async function requireSignedInSession(c: any, sessionId: string) {
|
||||
const session = await requireAppSessionRow(c, sessionId);
|
||||
if (!session.currentUserId || !session.currentUserEmail || !session.currentUserGithubLogin) {
|
||||
const auth = getBetterAuthService();
|
||||
const authState = await auth.getAuthState(sessionId);
|
||||
const user = authState?.user ?? null;
|
||||
const profile = authState?.profile ?? null;
|
||||
const githubAccount = authState?.accounts?.find((account: any) => account.providerId === "github") ?? null;
|
||||
if (!authState?.session || !user?.email) {
|
||||
throw new Error("User must be signed in");
|
||||
}
|
||||
return session;
|
||||
const token = await auth.getAccessTokenForSession(sessionId);
|
||||
return {
|
||||
...authState.session,
|
||||
authUserId: user.id,
|
||||
currentUserId: profile?.githubAccountId ?? githubAccount?.accountId ?? user.id,
|
||||
currentUserName: user.name,
|
||||
currentUserEmail: user.email,
|
||||
currentUserGithubLogin: profile?.githubLogin ?? "",
|
||||
currentUserRoleLabel: profile?.roleLabel ?? "GitHub user",
|
||||
eligibleOrganizationIdsJson: profile?.eligibleOrganizationIdsJson ?? "[]",
|
||||
githubAccessToken: token?.accessToken ?? null,
|
||||
githubScope: (token?.scopes ?? []).join(","),
|
||||
starterRepoStatus: profile?.starterRepoStatus ?? "pending",
|
||||
starterRepoStarredAt: profile?.starterRepoStarredAt ?? null,
|
||||
starterRepoSkippedAt: profile?.starterRepoSkippedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function requireEligibleOrganization(session: any, organizationId: string): void {
|
||||
|
|
@ -431,54 +459,26 @@ async function safeListInstallations(accessToken: string): Promise<any[]> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast path: resolve viewer identity, store user + token in the session,
|
||||
* and return the redirect URL. Does NOT sync organizations — that work is
|
||||
* deferred to `syncGithubOrganizations` via the workflow queue so the HTTP
|
||||
* callback can respond before any proxy timeout triggers a retry.
|
||||
*/
|
||||
async function initGithubSession(c: any, sessionId: string, accessToken: string, scopes: string[]): Promise<{ sessionId: string; redirectTo: string }> {
|
||||
assertAppWorkspace(c);
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const viewer = await appShell.github.getViewer(accessToken);
|
||||
const userId = `user-${slugify(viewer.login)}`;
|
||||
|
||||
await updateAppSession(c, sessionId, {
|
||||
currentUserId: userId,
|
||||
currentUserName: viewer.name || viewer.login,
|
||||
currentUserEmail: viewer.email ?? `${viewer.login}@users.noreply.github.com`,
|
||||
currentUserGithubLogin: viewer.login,
|
||||
currentUserRoleLabel: "GitHub user",
|
||||
githubAccessToken: accessToken,
|
||||
githubScope: scopes.join(","),
|
||||
oauthState: null,
|
||||
oauthStateExpiresAt: null,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
redirectTo: `${appShell.appUrl}/organizations?foundrySession=${encodeURIComponent(sessionId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Slow path: list GitHub orgs + installations, sync each org workspace,
|
||||
* and update the session's eligible organization list. Called from the
|
||||
* workflow queue so it runs in the background after the callback has
|
||||
* already returned a redirect to the browser.
|
||||
*
|
||||
* Also used synchronously by bootstrapAppGithubSession (dev-only) where
|
||||
* proxy timeouts are not a concern.
|
||||
*/
|
||||
export async function syncGithubOrganizations(c: any, input: { sessionId: string; accessToken: string }): Promise<void> {
|
||||
assertAppWorkspace(c);
|
||||
const auth = getBetterAuthService();
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const { sessionId, accessToken } = input;
|
||||
const session = await requireAppSessionRow(c, sessionId);
|
||||
const authState = await auth.getAuthState(sessionId);
|
||||
if (!authState?.user) {
|
||||
throw new Error("User must be signed in");
|
||||
}
|
||||
const viewer = await appShell.github.getViewer(accessToken);
|
||||
const organizations = await safeListOrganizations(accessToken);
|
||||
const installations = await safeListInstallations(accessToken);
|
||||
const userId = `user-${slugify(viewer.login)}`;
|
||||
const authUserId = authState.user.id;
|
||||
const githubUserId = String(viewer.id);
|
||||
|
||||
const linkedOrganizationIds: string[] = [];
|
||||
const accounts = [
|
||||
|
|
@ -503,7 +503,7 @@ export async function syncGithubOrganizations(c: any, input: { sessionId: string
|
|||
const installation = installations.find((candidate) => candidate.accountLogin === account.githubLogin) ?? null;
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
await workspace.syncOrganizationShellFromGithub({
|
||||
userId,
|
||||
userId: githubUserId,
|
||||
userName: viewer.name || viewer.login,
|
||||
userEmail: viewer.email ?? `${viewer.login}@users.noreply.github.com`,
|
||||
githubUserLogin: viewer.login,
|
||||
|
|
@ -519,16 +519,20 @@ export async function syncGithubOrganizations(c: any, input: { sessionId: string
|
|||
}
|
||||
|
||||
const activeOrganizationId =
|
||||
session.activeOrganizationId && linkedOrganizationIds.includes(session.activeOrganizationId)
|
||||
? session.activeOrganizationId
|
||||
authState.sessionState?.activeOrganizationId && linkedOrganizationIds.includes(authState.sessionState.activeOrganizationId)
|
||||
? authState.sessionState.activeOrganizationId
|
||||
: linkedOrganizationIds.length === 1
|
||||
? (linkedOrganizationIds[0] ?? null)
|
||||
: null;
|
||||
|
||||
await updateAppSession(c, sessionId, {
|
||||
await auth.setActiveOrganization(sessionId, activeOrganizationId);
|
||||
await auth.upsertUserProfile(authUserId, {
|
||||
githubAccountId: String(viewer.id),
|
||||
githubLogin: viewer.login,
|
||||
roleLabel: "GitHub user",
|
||||
eligibleOrganizationIdsJson: encodeEligibleOrganizationIds(linkedOrganizationIds),
|
||||
activeOrganizationId,
|
||||
});
|
||||
c.broadcast("appUpdated", { at: Date.now(), sessionId });
|
||||
}
|
||||
|
||||
export async function syncGithubOrganizationRepos(c: any, input: { sessionId: string; organizationId: string }): Promise<void> {
|
||||
|
|
@ -583,19 +587,6 @@ export async function syncGithubOrganizationRepos(c: any, input: { sessionId: st
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full synchronous sync: init session + sync orgs in one call.
|
||||
* Used by bootstrapAppGithubSession (dev-only) where there is no proxy
|
||||
* timeout concern and we want the session fully populated before returning.
|
||||
*/
|
||||
async function syncGithubSessionFromToken(c: any, sessionId: string, accessToken: string): Promise<{ sessionId: string; redirectTo: string }> {
|
||||
const session = await requireAppSessionRow(c, sessionId);
|
||||
const scopes = splitScopes(session.githubScope);
|
||||
const result = await initGithubSession(c, sessionId, accessToken, scopes);
|
||||
await syncGithubOrganizations(c, { sessionId, accessToken });
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readOrganizationProfileRow(c: any) {
|
||||
assertOrganizationWorkspace(c);
|
||||
return await c.db.select().from(organizationProfile).where(eq(organizationProfile.id, PROFILE_ROW_ID)).get();
|
||||
|
|
@ -736,9 +727,253 @@ async function applySubscriptionState(
|
|||
}
|
||||
|
||||
export const workspaceAppActions = {
|
||||
async ensureAppSession(c: any, input?: { requestedSessionId?: string | null }): Promise<{ sessionId: string }> {
|
||||
const sessionId = await ensureAppSession(c, input?.requestedSessionId);
|
||||
return { sessionId };
|
||||
async authFindSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
const clauses = [
|
||||
...(input.sessionId ? [{ field: "sessionId", value: input.sessionId }] : []),
|
||||
...(input.sessionToken ? [{ field: "sessionToken", value: input.sessionToken }] : []),
|
||||
];
|
||||
if (clauses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const predicate = workspaceAuthWhere(authSessionIndex, clauses);
|
||||
return await c.db.select().from(authSessionIndex).where(predicate!).get();
|
||||
},
|
||||
|
||||
async authUpsertSessionIndex(c: any, input: { sessionId: string; sessionToken: string; userId: string }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(authSessionIndex)
|
||||
.values({
|
||||
sessionId: input.sessionId,
|
||||
sessionToken: input.sessionToken,
|
||||
userId: input.userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: authSessionIndex.sessionId,
|
||||
set: {
|
||||
sessionToken: input.sessionToken,
|
||||
userId: input.userId,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
return await c.db.select().from(authSessionIndex).where(eq(authSessionIndex.sessionId, input.sessionId)).get();
|
||||
},
|
||||
|
||||
async authDeleteSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
const clauses = [
|
||||
...(input.sessionId ? [{ field: "sessionId", value: input.sessionId }] : []),
|
||||
...(input.sessionToken ? [{ field: "sessionToken", value: input.sessionToken }] : []),
|
||||
];
|
||||
if (clauses.length === 0) {
|
||||
return;
|
||||
}
|
||||
const predicate = workspaceAuthWhere(authSessionIndex, clauses);
|
||||
await c.db.delete(authSessionIndex).where(predicate!).run();
|
||||
},
|
||||
|
||||
async authFindEmailIndex(c: any, input: { email: string }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
return await c.db.select().from(authEmailIndex).where(eq(authEmailIndex.email, input.email)).get();
|
||||
},
|
||||
|
||||
async authUpsertEmailIndex(c: any, input: { email: string; userId: string }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(authEmailIndex)
|
||||
.values({
|
||||
email: input.email,
|
||||
userId: input.userId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: authEmailIndex.email,
|
||||
set: {
|
||||
userId: input.userId,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
return await c.db.select().from(authEmailIndex).where(eq(authEmailIndex.email, input.email)).get();
|
||||
},
|
||||
|
||||
async authDeleteEmailIndex(c: any, input: { email: string }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
await c.db.delete(authEmailIndex).where(eq(authEmailIndex.email, input.email)).run();
|
||||
},
|
||||
|
||||
async authFindAccountIndex(c: any, input: { id?: string; providerId?: string; accountId?: string }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
if (input.id) {
|
||||
return await c.db.select().from(authAccountIndex).where(eq(authAccountIndex.id, input.id)).get();
|
||||
}
|
||||
if (!input.providerId || !input.accountId) {
|
||||
return null;
|
||||
}
|
||||
return await c.db
|
||||
.select()
|
||||
.from(authAccountIndex)
|
||||
.where(and(eq(authAccountIndex.providerId, input.providerId), eq(authAccountIndex.accountId, input.accountId)))
|
||||
.get();
|
||||
},
|
||||
|
||||
async authUpsertAccountIndex(c: any, input: { id: string; providerId: string; accountId: string; userId: string }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(authAccountIndex)
|
||||
.values({
|
||||
id: input.id,
|
||||
providerId: input.providerId,
|
||||
accountId: input.accountId,
|
||||
userId: input.userId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: authAccountIndex.id,
|
||||
set: {
|
||||
providerId: input.providerId,
|
||||
accountId: input.accountId,
|
||||
userId: input.userId,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
return await c.db.select().from(authAccountIndex).where(eq(authAccountIndex.id, input.id)).get();
|
||||
},
|
||||
|
||||
async authDeleteAccountIndex(c: any, input: { id?: string; providerId?: string; accountId?: string }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
if (input.id) {
|
||||
await c.db.delete(authAccountIndex).where(eq(authAccountIndex.id, input.id)).run();
|
||||
return;
|
||||
}
|
||||
if (input.providerId && input.accountId) {
|
||||
await c.db
|
||||
.delete(authAccountIndex)
|
||||
.where(and(eq(authAccountIndex.providerId, input.providerId), eq(authAccountIndex.accountId, input.accountId)))
|
||||
.run();
|
||||
}
|
||||
},
|
||||
|
||||
async authCreateVerification(c: any, input: { data: Record<string, unknown> }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
await c.db
|
||||
.insert(authVerification)
|
||||
.values(input.data as any)
|
||||
.run();
|
||||
return await c.db
|
||||
.select()
|
||||
.from(authVerification)
|
||||
.where(eq(authVerification.id, input.data.id as string))
|
||||
.get();
|
||||
},
|
||||
|
||||
async authFindOneVerification(c: any, input: { where: any[] }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
const predicate = workspaceAuthWhere(authVerification, input.where);
|
||||
return predicate ? await c.db.select().from(authVerification).where(predicate).get() : null;
|
||||
},
|
||||
|
||||
async authFindManyVerification(c: any, input: { where?: any[]; limit?: number; sortBy?: any; offset?: number }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
const predicate = workspaceAuthWhere(authVerification, input.where);
|
||||
let query = c.db.select().from(authVerification);
|
||||
if (predicate) {
|
||||
query = query.where(predicate);
|
||||
}
|
||||
if (input.sortBy?.field) {
|
||||
const column = workspaceAuthColumn(authVerification, input.sortBy.field);
|
||||
query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column));
|
||||
}
|
||||
if (typeof input.limit === "number") {
|
||||
query = query.limit(input.limit);
|
||||
}
|
||||
if (typeof input.offset === "number") {
|
||||
query = query.offset(input.offset);
|
||||
}
|
||||
return await query.all();
|
||||
},
|
||||
|
||||
async authUpdateVerification(c: any, input: { where: any[]; update: Record<string, unknown> }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
const predicate = workspaceAuthWhere(authVerification, input.where);
|
||||
if (!predicate) {
|
||||
return null;
|
||||
}
|
||||
await c.db
|
||||
.update(authVerification)
|
||||
.set(input.update as any)
|
||||
.where(predicate)
|
||||
.run();
|
||||
return await c.db.select().from(authVerification).where(predicate).get();
|
||||
},
|
||||
|
||||
async authUpdateManyVerification(c: any, input: { where: any[]; update: Record<string, unknown> }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
const predicate = workspaceAuthWhere(authVerification, input.where);
|
||||
if (!predicate) {
|
||||
return 0;
|
||||
}
|
||||
await c.db
|
||||
.update(authVerification)
|
||||
.set(input.update as any)
|
||||
.where(predicate)
|
||||
.run();
|
||||
const row = await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get();
|
||||
return row?.value ?? 0;
|
||||
},
|
||||
|
||||
async authDeleteVerification(c: any, input: { where: any[] }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
const predicate = workspaceAuthWhere(authVerification, input.where);
|
||||
if (!predicate) {
|
||||
return;
|
||||
}
|
||||
await c.db.delete(authVerification).where(predicate).run();
|
||||
},
|
||||
|
||||
async authDeleteManyVerification(c: any, input: { where: any[] }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
const predicate = workspaceAuthWhere(authVerification, input.where);
|
||||
if (!predicate) {
|
||||
return 0;
|
||||
}
|
||||
const rows = await c.db.select().from(authVerification).where(predicate).all();
|
||||
await c.db.delete(authVerification).where(predicate).run();
|
||||
return rows.length;
|
||||
},
|
||||
|
||||
async authCountVerification(c: any, input: { where?: any[] }) {
|
||||
assertAppWorkspace(c);
|
||||
|
||||
const predicate = workspaceAuthWhere(authVerification, input.where);
|
||||
const row = predicate
|
||||
? await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get()
|
||||
: await c.db.select({ value: sqlCount() }).from(authVerification).get();
|
||||
return row?.value ?? 0;
|
||||
},
|
||||
|
||||
async getAppSnapshot(c: any, input: { sessionId: string }): Promise<FoundryAppSnapshot> {
|
||||
|
|
@ -750,20 +985,27 @@ export const workspaceAppActions = {
|
|||
input: { organizationId: string; requireRepoScope?: boolean },
|
||||
): Promise<{ accessToken: string; scopes: string[] } | null> {
|
||||
assertAppWorkspace(c);
|
||||
const rows = await c.db.select().from(appSessions).orderBy(desc(appSessions.updatedAt)).all();
|
||||
const auth = getBetterAuthService();
|
||||
const rows = await c.db.select().from(authSessionIndex).orderBy(desc(authSessionIndex.updatedAt)).all();
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.activeOrganizationId !== input.organizationId || !row.githubAccessToken) {
|
||||
const authState = await auth.getAuthState(row.sessionId);
|
||||
if (authState?.sessionState?.activeOrganizationId !== input.organizationId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scopes = splitScopes(row.githubScope);
|
||||
if (input.requireRepoScope !== false && !hasRepoScope(scopes)) {
|
||||
const token = await auth.getAccessTokenForSession(row.sessionId);
|
||||
if (!token?.accessToken) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scopes = token.scopes;
|
||||
if (input.requireRepoScope !== false && scopes.length > 0 && !hasRepoScope(scopes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: row.githubAccessToken,
|
||||
accessToken: token.accessToken,
|
||||
scopes,
|
||||
};
|
||||
}
|
||||
|
|
@ -771,97 +1013,10 @@ export const workspaceAppActions = {
|
|||
return null;
|
||||
},
|
||||
|
||||
async startAppGithubAuth(c: any, input: { sessionId: string }): Promise<{ url: string }> {
|
||||
assertAppWorkspace(c);
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const sessionId = await ensureAppSession(c, input.sessionId);
|
||||
const nonce = randomUUID();
|
||||
await updateAppSession(c, sessionId, {
|
||||
oauthState: nonce,
|
||||
oauthStateExpiresAt: Date.now() + OAUTH_TTL_MS,
|
||||
});
|
||||
return {
|
||||
url: appShell.github.buildAuthorizeUrl(encodeOauthState({ sessionId, nonce })),
|
||||
};
|
||||
},
|
||||
|
||||
async completeAppGithubAuth(c: any, input: { code: string; state: string }): Promise<{ sessionId: string; redirectTo: string }> {
|
||||
assertAppWorkspace(c);
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const oauth = decodeOauthState(input.state);
|
||||
const session = await requireAppSessionRow(c, oauth.sessionId);
|
||||
if (!session.oauthState || session.oauthState !== oauth.nonce || !session.oauthStateExpiresAt || session.oauthStateExpiresAt < Date.now()) {
|
||||
throw new Error("GitHub OAuth state is invalid or expired");
|
||||
}
|
||||
|
||||
// Clear state before exchangeCode — GitHub codes are single-use and
|
||||
// duplicate callback requests (from proxy retries or user refresh)
|
||||
// must fail the state check rather than attempt a second exchange.
|
||||
// See research/friction/general.mdx 2026-03-13 entry.
|
||||
await updateAppSession(c, session.id, {
|
||||
oauthState: null,
|
||||
oauthStateExpiresAt: null,
|
||||
});
|
||||
|
||||
const token = await appShell.github.exchangeCode(input.code);
|
||||
|
||||
// Fast path: store token + user identity and return the redirect
|
||||
// immediately. The slow org sync (list orgs, list installations,
|
||||
// sync each workspace) runs in the workflow queue so the HTTP
|
||||
// response lands before any proxy/infra timeout triggers a retry.
|
||||
// The frontend already polls when it sees syncStatus === "syncing".
|
||||
const result = await initGithubSession(c, session.id, token.accessToken, token.scopes);
|
||||
|
||||
// Enqueue the slow org sync to the workflow. fire-and-forget (wait: false)
|
||||
// because the redirect does not depend on org data — the frontend will
|
||||
// poll getAppSnapshot until organizations are populated.
|
||||
const self = selfWorkspace(c);
|
||||
await self.send(
|
||||
"workspace.command.syncGithubSession",
|
||||
{ sessionId: session.id, accessToken: token.accessToken },
|
||||
{
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async bootstrapAppGithubSession(c: any, input: { accessToken: string; sessionId?: string | null }): Promise<{ sessionId: string; redirectTo: string }> {
|
||||
assertAppWorkspace(c);
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("bootstrapAppGithubSession is development-only");
|
||||
}
|
||||
const sessionId = await ensureAppSession(c, input.sessionId ?? null);
|
||||
return await syncGithubSessionFromToken(c, sessionId, input.accessToken);
|
||||
},
|
||||
|
||||
async signOutApp(c: any, input: { sessionId: string }): Promise<FoundryAppSnapshot> {
|
||||
assertAppWorkspace(c);
|
||||
const sessionId = await ensureAppSession(c, input.sessionId);
|
||||
await updateAppSession(c, sessionId, {
|
||||
currentUserId: null,
|
||||
currentUserName: null,
|
||||
currentUserEmail: null,
|
||||
currentUserGithubLogin: null,
|
||||
currentUserRoleLabel: null,
|
||||
eligibleOrganizationIdsJson: "[]",
|
||||
activeOrganizationId: null,
|
||||
githubAccessToken: null,
|
||||
githubScope: "",
|
||||
starterRepoStatus: "pending",
|
||||
starterRepoStarredAt: null,
|
||||
starterRepoSkippedAt: null,
|
||||
oauthState: null,
|
||||
oauthStateExpiresAt: null,
|
||||
});
|
||||
return await buildAppSnapshot(c, sessionId);
|
||||
},
|
||||
|
||||
async skipAppStarterRepo(c: any, input: { sessionId: string }): Promise<FoundryAppSnapshot> {
|
||||
assertAppWorkspace(c);
|
||||
await requireSignedInSession(c, input.sessionId);
|
||||
await updateAppSession(c, input.sessionId, {
|
||||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
await getBetterAuthService().upsertUserProfile(session.authUserId, {
|
||||
starterRepoStatus: "skipped",
|
||||
starterRepoSkippedAt: Date.now(),
|
||||
starterRepoStarredAt: null,
|
||||
|
|
@ -877,7 +1032,7 @@ export const workspaceAppActions = {
|
|||
await workspace.starSandboxAgentRepo({
|
||||
workspaceId: input.organizationId,
|
||||
});
|
||||
await updateAppSession(c, input.sessionId, {
|
||||
await getBetterAuthService().upsertUserProfile(session.authUserId, {
|
||||
starterRepoStatus: "starred",
|
||||
starterRepoStarredAt: Date.now(),
|
||||
starterRepoSkippedAt: null,
|
||||
|
|
@ -889,9 +1044,7 @@ export const workspaceAppActions = {
|
|||
assertAppWorkspace(c);
|
||||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
requireEligibleOrganization(session, input.organizationId);
|
||||
await updateAppSession(c, input.sessionId, {
|
||||
activeOrganizationId: input.organizationId,
|
||||
});
|
||||
await getBetterAuthService().setActiveOrganization(input.sessionId, input.organizationId);
|
||||
|
||||
const workspace = await getOrCreateWorkspace(c, input.organizationId);
|
||||
const organization = await getOrganizationState(workspace);
|
||||
|
|
@ -968,7 +1121,7 @@ export const workspaceAppActions = {
|
|||
const organization = await getOrganizationState(workspace);
|
||||
if (organization.snapshot.kind !== "organization") {
|
||||
return {
|
||||
url: `${appShell.appUrl}/workspaces/${input.organizationId}?foundrySession=${encodeURIComponent(input.sessionId)}`,
|
||||
url: `${appShell.appUrl}/workspaces/${input.organizationId}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
@ -987,7 +1140,7 @@ export const workspaceAppActions = {
|
|||
if (input.planId === "free") {
|
||||
await workspace.applyOrganizationFreePlan({ clearSubscription: false });
|
||||
return {
|
||||
url: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`,
|
||||
url: `${appShell.appUrl}/organizations/${input.organizationId}/billing`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1017,8 +1170,8 @@ export const workspaceAppActions = {
|
|||
planId: input.planId,
|
||||
successUrl: `${appShell.apiUrl}/v1/billing/checkout/complete?organizationId=${encodeURIComponent(
|
||||
input.organizationId,
|
||||
)}&foundrySession=${encodeURIComponent(input.sessionId)}&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancelUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`,
|
||||
)}&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancelUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing`,
|
||||
})
|
||||
.then((checkout) => checkout.url),
|
||||
};
|
||||
|
|
@ -1048,7 +1201,7 @@ export const workspaceAppActions = {
|
|||
}
|
||||
|
||||
return {
|
||||
redirectTo: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`,
|
||||
redirectTo: `${appShell.appUrl}/organizations/${input.organizationId}/billing`,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -1064,7 +1217,7 @@ export const workspaceAppActions = {
|
|||
}
|
||||
const portal = await appShell.stripe.createPortalSession({
|
||||
customerId: organization.stripeCustomerId,
|
||||
returnUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`,
|
||||
returnUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing`,
|
||||
});
|
||||
return { url: portal.url };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ const journal = {
|
|||
tag: "0000_melted_viper",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 1,
|
||||
when: 1773638400000,
|
||||
tag: "0001_auth_index_tables",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -113,6 +119,37 @@ 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
|
||||
);
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -74,23 +74,33 @@ export const invoices = sqliteTable("invoices", {
|
|||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
|
||||
export const appSessions = sqliteTable("app_sessions", {
|
||||
export const authSessionIndex = sqliteTable("auth_session_index", {
|
||||
sessionId: text("session_id").notNull().primaryKey(),
|
||||
sessionToken: text("session_token").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const authEmailIndex = sqliteTable("auth_email_index", {
|
||||
email: text("email").notNull().primaryKey(),
|
||||
userId: text("user_id").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const authAccountIndex = sqliteTable("auth_account_index", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
currentUserId: text("current_user_id"),
|
||||
currentUserName: text("current_user_name"),
|
||||
currentUserEmail: text("current_user_email"),
|
||||
currentUserGithubLogin: text("current_user_github_login"),
|
||||
currentUserRoleLabel: text("current_user_role_label"),
|
||||
// Structured as a JSON array of eligible organization ids for the session.
|
||||
eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
githubAccessToken: text("github_access_token"),
|
||||
githubScope: text("github_scope").notNull(),
|
||||
starterRepoStatus: text("starter_repo_status").notNull(),
|
||||
starterRepoStarredAt: integer("starter_repo_starred_at"),
|
||||
starterRepoSkippedAt: integer("starter_repo_skipped_at"),
|
||||
oauthState: text("oauth_state"),
|
||||
oauthStateExpiresAt: integer("oauth_state_expires_at"),
|
||||
providerId: text("provider_id").notNull(),
|
||||
accountId: text("account_id").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const authVerification = sqliteTable("auth_verification", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { createDefaultDriver } from "./driver.js";
|
|||
import { createProviderRegistry } from "./providers/index.js";
|
||||
import { createClient } from "rivetkit/client";
|
||||
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
|
||||
import { initBetterAuthService } from "./services/better-auth.js";
|
||||
import { createDefaultAppShellServices } from "./services/app-shell-runtime.js";
|
||||
import { APP_SHELL_WORKSPACE_ID } from "./actors/workspace/app-shell.js";
|
||||
import { logger } from "./logging.js";
|
||||
|
|
@ -39,33 +40,15 @@ interface AppWorkspaceLogContext {
|
|||
xRealIp?: string;
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value: string): string {
|
||||
return value.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function isRivetRequest(request: Request): boolean {
|
||||
const { pathname } = new URL(request.url);
|
||||
return pathname === "/v1/rivet" || pathname.startsWith("/v1/rivet/");
|
||||
}
|
||||
|
||||
function isRetryableAppActorError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes("Actor not ready") || message.includes("socket connection was closed unexpectedly");
|
||||
}
|
||||
|
||||
async function withRetries<T>(run: () => Promise<T>, attempts = 20, delayMs = 250): Promise<T> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isRetryableAppActorError(error) || attempt === attempts) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
||||
}
|
||||
|
||||
export async function startBackend(options: BackendStartOptions = {}): Promise<void> {
|
||||
// sandbox-agent agent plugins vary on which env var they read for OpenAI/Codex auth.
|
||||
// Normalize to keep local dev + docker-compose simple.
|
||||
|
|
@ -94,11 +77,16 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
const providers = createProviderRegistry(config, driver);
|
||||
const backends = await createBackends(config.notify);
|
||||
const notifications = createNotificationService(backends);
|
||||
initActorRuntimeContext(config, providers, notifications, driver, createDefaultAppShellServices());
|
||||
const appShellServices = createDefaultAppShellServices();
|
||||
initActorRuntimeContext(config, providers, notifications, driver, appShellServices);
|
||||
|
||||
const actorClient = createClient({
|
||||
endpoint: `http://127.0.0.1:${config.backend.port}/v1/rivet`,
|
||||
}) as any;
|
||||
const betterAuth = initBetterAuthService(actorClient, {
|
||||
apiUrl: appShellServices.apiUrl,
|
||||
appUrl: appShellServices.appUrl,
|
||||
});
|
||||
|
||||
const requestHeaderContext = (c: any): AppWorkspaceLogContext => ({
|
||||
cfConnectingIp: c.req.header("cf-connecting-ip") ?? undefined,
|
||||
|
|
@ -131,29 +119,18 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
"x-rivet-total-slots",
|
||||
"x-rivet-runner-name",
|
||||
"x-rivet-namespace-name",
|
||||
"x-foundry-session",
|
||||
];
|
||||
const exposeHeaders = ["Content-Type", "x-foundry-session", "x-rivet-ray-id"];
|
||||
app.use(
|
||||
"/v1/*",
|
||||
cors({
|
||||
origin: (origin) => origin ?? "*",
|
||||
credentials: true,
|
||||
allowHeaders,
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
exposeHeaders,
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
"/v1",
|
||||
cors({
|
||||
origin: (origin) => origin ?? "*",
|
||||
credentials: true,
|
||||
allowHeaders,
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
exposeHeaders,
|
||||
}),
|
||||
);
|
||||
const exposeHeaders = ["Content-Type", "x-rivet-ray-id"];
|
||||
const allowedOrigins = new Set([stripTrailingSlash(appShellServices.appUrl), stripTrailingSlash(appShellServices.apiUrl)]);
|
||||
const corsConfig = {
|
||||
origin: (origin: string) => (allowedOrigins.has(origin) ? origin : null) as string | undefined | null,
|
||||
credentials: true,
|
||||
allowHeaders,
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
exposeHeaders,
|
||||
};
|
||||
app.use("/v1/*", cors(corsConfig));
|
||||
app.use("/v1", cors(corsConfig));
|
||||
app.use("*", async (c, next) => {
|
||||
const requestId = c.req.header("x-request-id")?.trim() || randomUUID();
|
||||
const start = performance.now();
|
||||
|
|
@ -190,6 +167,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
);
|
||||
});
|
||||
|
||||
// Cache the app workspace actor handle for the lifetime of this backend process.
|
||||
// The "app" workspace is a singleton coordinator for auth indexes, org state, and
|
||||
// billing. Caching avoids repeated getOrCreate round-trips on every HTTP request.
|
||||
let cachedAppWorkspace: any | null = null;
|
||||
|
||||
const appWorkspace = async (context: AppWorkspaceLogContext = {}) => {
|
||||
|
|
@ -197,12 +177,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
|
||||
const start = performance.now();
|
||||
try {
|
||||
const handle = await withRetries(
|
||||
async () =>
|
||||
await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
|
||||
createWithInput: APP_SHELL_WORKSPACE_ID,
|
||||
}),
|
||||
);
|
||||
const handle = await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
|
||||
createWithInput: APP_SHELL_WORKSPACE_ID,
|
||||
});
|
||||
cachedAppWorkspace = handle;
|
||||
logger.info(
|
||||
{
|
||||
|
|
@ -253,68 +230,70 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
sessionId,
|
||||
});
|
||||
|
||||
const resolveSessionId = async (c: any): Promise<string> => {
|
||||
const requested = c.req.header("x-foundry-session");
|
||||
const { sessionId } = await appWorkspaceAction(
|
||||
"ensureAppSession",
|
||||
async (workspace) => await workspace.ensureAppSession(requested && requested.trim().length > 0 ? { requestedSessionId: requested } : {}),
|
||||
requestLogContext(c),
|
||||
);
|
||||
c.header("x-foundry-session", sessionId);
|
||||
return sessionId;
|
||||
const resolveSessionId = async (c: any): Promise<string | null> => {
|
||||
const session = await betterAuth.resolveSession(c.req.raw.headers);
|
||||
return session?.session?.id ?? null;
|
||||
};
|
||||
|
||||
app.get("/v1/app/snapshot", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.json({
|
||||
auth: { status: "signed_out", currentUserId: null },
|
||||
activeOrganizationId: null,
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
repoFullName: "rivet-dev/sandbox-agent",
|
||||
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
|
||||
status: "pending",
|
||||
starredAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
},
|
||||
users: [],
|
||||
organizations: [],
|
||||
});
|
||||
}
|
||||
return c.json(
|
||||
await appWorkspaceAction("getAppSnapshot", async (workspace) => await workspace.getAppSnapshot({ sessionId }), requestLogContext(c, sessionId)),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/v1/auth/github/start", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
const result = await appWorkspaceAction(
|
||||
"startAppGithubAuth",
|
||||
async (workspace) => await workspace.startAppGithubAuth({ sessionId }),
|
||||
requestLogContext(c, sessionId),
|
||||
);
|
||||
return Response.redirect(result.url, 302);
|
||||
app.all("/v1/auth/*", async (c) => {
|
||||
return await betterAuth.auth.handler(c.req.raw);
|
||||
});
|
||||
|
||||
const handleGithubAuthCallback = async (c: any) => {
|
||||
// TEMPORARY: dump all request headers to diagnose duplicate callback requests
|
||||
// (Railway nginx proxy_next_upstream? Cloudflare retry? browser?)
|
||||
// Remove once root cause is identified.
|
||||
const allHeaders: Record<string, string> = {};
|
||||
c.req.raw.headers.forEach((value: string, key: string) => {
|
||||
allHeaders[key] = value;
|
||||
});
|
||||
logger.info({ headers: allHeaders, url: c.req.url }, "github_callback_headers");
|
||||
|
||||
const code = c.req.query("code");
|
||||
const state = c.req.query("state");
|
||||
if (!code || !state) {
|
||||
return c.text("Missing GitHub OAuth callback parameters", 400);
|
||||
}
|
||||
const result = await appWorkspaceAction(
|
||||
"completeAppGithubAuth",
|
||||
async (workspace) => await workspace.completeAppGithubAuth({ code, state }),
|
||||
requestLogContext(c),
|
||||
);
|
||||
c.header("x-foundry-session", result.sessionId);
|
||||
return Response.redirect(result.redirectTo, 302);
|
||||
};
|
||||
|
||||
app.get("/v1/auth/github/callback", handleGithubAuthCallback);
|
||||
app.get("/api/auth/callback/github", handleGithubAuthCallback);
|
||||
|
||||
app.post("/v1/app/sign-out", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(await appWorkspaceAction("signOutApp", async (workspace) => await workspace.signOutApp({ sessionId }), requestLogContext(c, sessionId)));
|
||||
if (sessionId) {
|
||||
const signOutResponse = await betterAuth.signOut(c.req.raw.headers);
|
||||
const setCookie = signOutResponse.headers.get("set-cookie");
|
||||
if (setCookie) {
|
||||
c.header("set-cookie", setCookie);
|
||||
}
|
||||
}
|
||||
return c.json({
|
||||
auth: { status: "signed_out", currentUserId: null },
|
||||
activeOrganizationId: null,
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
repoFullName: "rivet-dev/sandbox-agent",
|
||||
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
|
||||
status: "pending",
|
||||
starredAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
},
|
||||
users: [],
|
||||
organizations: [],
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/v1/app/onboarding/starter-repo/skip", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await appWorkspaceAction("skipAppStarterRepo", async (workspace) => await workspace.skipAppStarterRepo({ sessionId }), requestLogContext(c, sessionId)),
|
||||
);
|
||||
|
|
@ -322,6 +301,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
|
||||
app.post("/v1/app/organizations/:organizationId/starter-repo/star", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"starAppStarterRepo",
|
||||
|
|
@ -337,6 +319,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
|
||||
app.post("/v1/app/organizations/:organizationId/select", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"selectAppOrganization",
|
||||
|
|
@ -352,6 +337,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
|
||||
app.patch("/v1/app/organizations/:organizationId/profile", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
|
|
@ -371,6 +359,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
|
||||
app.post("/v1/app/organizations/:organizationId/import", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"triggerAppRepoImport",
|
||||
|
|
@ -386,6 +377,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
|
||||
app.post("/v1/app/organizations/:organizationId/reconnect", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"beginAppGithubInstall",
|
||||
|
|
@ -401,6 +395,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
|
||||
app.post("/v1/app/organizations/:organizationId/billing/checkout", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const planId = body?.planId === "free" || body?.planId === "team" ? (body.planId as FoundryBillingPlanId) : "team";
|
||||
return c.json(
|
||||
|
|
@ -414,11 +411,14 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
|
||||
app.get("/v1/billing/checkout/complete", async (c) => {
|
||||
const organizationId = c.req.query("organizationId");
|
||||
const sessionId = c.req.query("foundrySession");
|
||||
const checkoutSessionId = c.req.query("session_id");
|
||||
if (!organizationId || !sessionId || !checkoutSessionId) {
|
||||
if (!organizationId || !checkoutSessionId) {
|
||||
return c.text("Missing Stripe checkout completion parameters", 400);
|
||||
}
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
const result = await (await appWorkspace(requestLogContext(c, sessionId))).finalizeAppCheckoutSession({
|
||||
organizationId,
|
||||
sessionId,
|
||||
|
|
@ -429,6 +429,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
|
||||
app.post("/v1/app/organizations/:organizationId/billing/portal", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).createAppBillingPortalSession({
|
||||
sessionId,
|
||||
|
|
@ -439,6 +442,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
|
||||
app.post("/v1/app/organizations/:organizationId/billing/cancel", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).cancelAppScheduledRenewal({
|
||||
sessionId,
|
||||
|
|
@ -449,6 +455,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
|
||||
app.post("/v1/app/organizations/:organizationId/billing/resume", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).resumeAppSubscription({
|
||||
sessionId,
|
||||
|
|
@ -459,6 +468,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
|
||||
app.post("/v1/app/workspaces/:workspaceId/seat-usage", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).recordAppSeatUsage({
|
||||
sessionId,
|
||||
|
|
|
|||
533
foundry/packages/backend/src/services/better-auth.ts
Normal file
533
foundry/packages/backend/src/services/better-auth.ts
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
import { betterAuth } from "better-auth";
|
||||
import { createAdapterFactory } from "better-auth/adapters";
|
||||
import { APP_SHELL_WORKSPACE_ID } from "../actors/workspace/app-shell.js";
|
||||
import { authUserKey, workspaceKey } from "../actors/keys.js";
|
||||
import { logger } from "../logging.js";
|
||||
|
||||
const AUTH_BASE_PATH = "/v1/auth";
|
||||
const SESSION_COOKIE = "better-auth.session_token";
|
||||
|
||||
let betterAuthService: BetterAuthService | null = null;
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value: string): string {
|
||||
return value.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function buildCookieHeaders(sessionToken: string): Headers {
|
||||
return new Headers({
|
||||
cookie: `${SESSION_COOKIE}=${encodeURIComponent(sessionToken)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function readJsonSafe(response: Response): Promise<any> {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function callAuthEndpoint(auth: any, url: string, init?: RequestInit): Promise<Response> {
|
||||
return await auth.handler(new Request(url, init));
|
||||
}
|
||||
|
||||
function resolveRouteUserId(workspace: any, resolved: any): string | null {
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
if (typeof resolved === "string") {
|
||||
return resolved;
|
||||
}
|
||||
if (typeof resolved.userId === "string" && resolved.userId.length > 0) {
|
||||
return resolved.userId;
|
||||
}
|
||||
if (typeof resolved.id === "string" && resolved.id.length > 0) {
|
||||
return resolved.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface BetterAuthService {
|
||||
auth: any;
|
||||
resolveSession(headers: Headers): Promise<{ session: any; user: any } | null>;
|
||||
signOut(headers: Headers): Promise<Response>;
|
||||
getAuthState(sessionId: string): Promise<any | null>;
|
||||
upsertUserProfile(userId: string, patch: Record<string, unknown>): Promise<any>;
|
||||
setActiveOrganization(sessionId: string, activeOrganizationId: string | null): Promise<any>;
|
||||
getAccessTokenForSession(sessionId: string): Promise<{ accessToken: string; scopes: string[] } | null>;
|
||||
}
|
||||
|
||||
export function initBetterAuthService(actorClient: any, options: { apiUrl: string; appUrl: string }): BetterAuthService {
|
||||
if (betterAuthService) {
|
||||
return betterAuthService;
|
||||
}
|
||||
|
||||
// getOrCreate is intentional here: the adapter runs during Better Auth callbacks
|
||||
// which can fire before any explicit create path. The app workspace and auth user
|
||||
// actors must exist by the time the adapter needs them.
|
||||
const appWorkspace = () =>
|
||||
actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
|
||||
createWithInput: APP_SHELL_WORKSPACE_ID,
|
||||
});
|
||||
|
||||
// getOrCreate is intentional: Better Auth creates user records during OAuth
|
||||
// callbacks, so the auth-user actor must be lazily provisioned on first access.
|
||||
const getAuthUser = async (userId: string) =>
|
||||
await actorClient.authUser.getOrCreate(authUserKey(userId), {
|
||||
createWithInput: { userId },
|
||||
});
|
||||
|
||||
const adapter = createAdapterFactory({
|
||||
config: {
|
||||
adapterId: "rivetkit-actor",
|
||||
adapterName: "RivetKit Actor Adapter",
|
||||
supportsBooleans: false,
|
||||
supportsDates: false,
|
||||
supportsJSON: false,
|
||||
},
|
||||
adapter: ({ transformInput, transformOutput, transformWhereClause }) => {
|
||||
const resolveUserIdForQuery = async (model: string, where?: any[], data?: Record<string, unknown>): Promise<string | null> => {
|
||||
const clauses = where ?? [];
|
||||
const direct = (field: string) => clauses.find((entry) => entry.field === field)?.value;
|
||||
|
||||
if (model === "user") {
|
||||
const fromId = direct("id") ?? data?.id;
|
||||
if (typeof fromId === "string" && fromId.length > 0) {
|
||||
return fromId;
|
||||
}
|
||||
const email = direct("email");
|
||||
if (typeof email === "string" && email.length > 0) {
|
||||
const workspace = await appWorkspace();
|
||||
const resolved = await workspace.authFindEmailIndex({ email: email.toLowerCase() });
|
||||
return resolveRouteUserId(workspace, resolved);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (model === "session") {
|
||||
const fromUserId = direct("userId") ?? data?.userId;
|
||||
if (typeof fromUserId === "string" && fromUserId.length > 0) {
|
||||
return fromUserId;
|
||||
}
|
||||
const sessionId = direct("id") ?? data?.id;
|
||||
const sessionToken = direct("token") ?? data?.token;
|
||||
if (typeof sessionId === "string" || typeof sessionToken === "string") {
|
||||
const workspace = await appWorkspace();
|
||||
const resolved = await workspace.authFindSessionIndex({
|
||||
...(typeof sessionId === "string" ? { sessionId } : {}),
|
||||
...(typeof sessionToken === "string" ? { sessionToken } : {}),
|
||||
});
|
||||
return resolveRouteUserId(workspace, resolved);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (model === "account") {
|
||||
const fromUserId = direct("userId") ?? data?.userId;
|
||||
if (typeof fromUserId === "string" && fromUserId.length > 0) {
|
||||
return fromUserId;
|
||||
}
|
||||
const accountRecordId = direct("id") ?? data?.id;
|
||||
const providerId = direct("providerId") ?? data?.providerId;
|
||||
const accountId = direct("accountId") ?? data?.accountId;
|
||||
const workspace = await appWorkspace();
|
||||
if (typeof accountRecordId === "string" && accountRecordId.length > 0) {
|
||||
const resolved = await workspace.authFindAccountIndex({ id: accountRecordId });
|
||||
return resolveRouteUserId(workspace, resolved);
|
||||
}
|
||||
if (typeof providerId === "string" && providerId.length > 0 && typeof accountId === "string" && accountId.length > 0) {
|
||||
const resolved = await workspace.authFindAccountIndex({ providerId, accountId });
|
||||
return resolveRouteUserId(workspace, resolved);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const ensureWorkspaceVerification = async (method: string, payload: Record<string, unknown>) => {
|
||||
const workspace = await appWorkspace();
|
||||
return await workspace[method](payload);
|
||||
};
|
||||
|
||||
return {
|
||||
options: {
|
||||
useDatabaseGeneratedIds: false,
|
||||
},
|
||||
|
||||
create: async ({ model, data }) => {
|
||||
const transformed = await transformInput(data, model, "create", true);
|
||||
if (model === "verification") {
|
||||
return await ensureWorkspaceVerification("authCreateVerification", { data: transformed });
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, undefined, transformed);
|
||||
if (!userId) {
|
||||
throw new Error(`Unable to resolve auth actor for create(${model})`);
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
const created = await userActor.createAuthRecord({ model, data: transformed });
|
||||
const workspace = await appWorkspace();
|
||||
|
||||
if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) {
|
||||
await workspace.authUpsertEmailIndex({
|
||||
email: transformed.email.toLowerCase(),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (model === "session") {
|
||||
await workspace.authUpsertSessionIndex({
|
||||
sessionId: String(created.id),
|
||||
sessionToken: String(created.token),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (model === "account") {
|
||||
await workspace.authUpsertAccountIndex({
|
||||
id: String(created.id),
|
||||
providerId: String(created.providerId),
|
||||
accountId: String(created.accountId),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
return (await transformOutput(created, model)) as any;
|
||||
},
|
||||
|
||||
findOne: async ({ model, where, join }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "findOne" });
|
||||
if (model === "verification") {
|
||||
return await ensureWorkspaceVerification("authFindOneVerification", { where: transformedWhere, join });
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, transformedWhere);
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
const found = await userActor.findOneAuthRecord({ model, where: transformedWhere, join });
|
||||
return found ? ((await transformOutput(found, model, undefined, join)) as any) : null;
|
||||
},
|
||||
|
||||
findMany: async ({ model, where, limit, sortBy, offset, join }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "findMany" });
|
||||
if (model === "verification") {
|
||||
return await ensureWorkspaceVerification("authFindManyVerification", {
|
||||
where: transformedWhere,
|
||||
limit,
|
||||
sortBy,
|
||||
offset,
|
||||
join,
|
||||
});
|
||||
}
|
||||
|
||||
if (model === "session") {
|
||||
const tokenClause = transformedWhere?.find((entry: any) => entry.field === "token" && entry.operator === "in");
|
||||
if (tokenClause && Array.isArray(tokenClause.value)) {
|
||||
const workspace = await appWorkspace();
|
||||
const resolved = await Promise.all(
|
||||
(tokenClause.value as string[]).map(async (sessionToken: string) => ({
|
||||
sessionToken,
|
||||
route: await workspace.authFindSessionIndex({ sessionToken }),
|
||||
})),
|
||||
);
|
||||
const byUser = new Map<string, string[]>();
|
||||
for (const item of resolved) {
|
||||
if (!item.route?.userId) {
|
||||
continue;
|
||||
}
|
||||
const tokens = byUser.get(item.route.userId) ?? [];
|
||||
tokens.push(item.sessionToken);
|
||||
byUser.set(item.route.userId, tokens);
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const [userId, tokens] of byUser) {
|
||||
const userActor = await getAuthUser(userId);
|
||||
const scopedWhere = transformedWhere.map((entry: any) =>
|
||||
entry.field === "token" && entry.operator === "in" ? { ...entry, value: tokens } : entry,
|
||||
);
|
||||
const found = await userActor.findManyAuthRecords({ model, where: scopedWhere, limit, sortBy, offset, join });
|
||||
rows.push(...found);
|
||||
}
|
||||
return await Promise.all(rows.map(async (row: any) => await transformOutput(row, model, undefined, join)));
|
||||
}
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, transformedWhere);
|
||||
if (!userId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
const found = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit, sortBy, offset, join });
|
||||
return await Promise.all(found.map(async (row: any) => await transformOutput(row, model, undefined, join)));
|
||||
},
|
||||
|
||||
update: async ({ model, where, update }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "update" });
|
||||
const transformedUpdate = (await transformInput(update as Record<string, unknown>, model, "update", true)) as Record<string, unknown>;
|
||||
if (model === "verification") {
|
||||
return await ensureWorkspaceVerification("authUpdateVerification", { where: transformedWhere, update: transformedUpdate });
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate);
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
const before =
|
||||
model === "user"
|
||||
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
|
||||
: model === "account"
|
||||
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
|
||||
: model === "session"
|
||||
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
|
||||
: null;
|
||||
const updated = await userActor.updateAuthRecord({ model, where: transformedWhere, update: transformedUpdate });
|
||||
const workspace = await appWorkspace();
|
||||
|
||||
if (model === "user" && updated) {
|
||||
if (before?.email && before.email !== updated.email) {
|
||||
await workspace.authDeleteEmailIndex({ email: before.email.toLowerCase() });
|
||||
}
|
||||
if (updated.email) {
|
||||
await workspace.authUpsertEmailIndex({ email: updated.email.toLowerCase(), userId });
|
||||
}
|
||||
}
|
||||
|
||||
if (model === "session" && updated) {
|
||||
await workspace.authUpsertSessionIndex({
|
||||
sessionId: String(updated.id),
|
||||
sessionToken: String(updated.token),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (model === "account" && updated) {
|
||||
await workspace.authUpsertAccountIndex({
|
||||
id: String(updated.id),
|
||||
providerId: String(updated.providerId),
|
||||
accountId: String(updated.accountId),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
return updated ? ((await transformOutput(updated, model)) as any) : null;
|
||||
},
|
||||
|
||||
updateMany: async ({ model, where, update }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "updateMany" });
|
||||
const transformedUpdate = (await transformInput(update as Record<string, unknown>, model, "update", true)) as Record<string, unknown>;
|
||||
if (model === "verification") {
|
||||
return await ensureWorkspaceVerification("authUpdateManyVerification", { where: transformedWhere, update: transformedUpdate });
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate);
|
||||
if (!userId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
return await userActor.updateManyAuthRecords({ model, where: transformedWhere, update: transformedUpdate });
|
||||
},
|
||||
|
||||
delete: async ({ model, where }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "delete" });
|
||||
if (model === "verification") {
|
||||
await ensureWorkspaceVerification("authDeleteVerification", { where: transformedWhere });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, transformedWhere);
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
const workspace = await appWorkspace();
|
||||
const before = await userActor.findOneAuthRecord({ model, where: transformedWhere });
|
||||
await userActor.deleteAuthRecord({ model, where: transformedWhere });
|
||||
|
||||
if (model === "session" && before) {
|
||||
await workspace.authDeleteSessionIndex({
|
||||
sessionId: before.id,
|
||||
sessionToken: before.token,
|
||||
});
|
||||
}
|
||||
|
||||
if (model === "account" && before) {
|
||||
await workspace.authDeleteAccountIndex({
|
||||
id: before.id,
|
||||
providerId: before.providerId,
|
||||
accountId: before.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
if (model === "user" && before?.email) {
|
||||
await workspace.authDeleteEmailIndex({ email: before.email.toLowerCase() });
|
||||
}
|
||||
},
|
||||
|
||||
deleteMany: async ({ model, where }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "deleteMany" });
|
||||
if (model === "verification") {
|
||||
return await ensureWorkspaceVerification("authDeleteManyVerification", { where: transformedWhere });
|
||||
}
|
||||
|
||||
if (model === "session") {
|
||||
const userId = await resolveUserIdForQuery(model, transformedWhere);
|
||||
if (!userId) {
|
||||
return 0;
|
||||
}
|
||||
const userActor = await getAuthUser(userId);
|
||||
const workspace = await appWorkspace();
|
||||
const sessions = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit: 5000 });
|
||||
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
|
||||
for (const session of sessions) {
|
||||
await workspace.authDeleteSessionIndex({
|
||||
sessionId: session.id,
|
||||
sessionToken: session.token,
|
||||
});
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, transformedWhere);
|
||||
if (!userId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
|
||||
return deleted;
|
||||
},
|
||||
|
||||
count: async ({ model, where }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "count" });
|
||||
if (model === "verification") {
|
||||
return await ensureWorkspaceVerification("authCountVerification", { where: transformedWhere });
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, transformedWhere);
|
||||
if (!userId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
return await userActor.countAuthRecords({ model, where: transformedWhere });
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const auth = betterAuth({
|
||||
baseURL: stripTrailingSlash(process.env.BETTER_AUTH_URL ?? options.apiUrl),
|
||||
basePath: AUTH_BASE_PATH,
|
||||
secret: requireEnv("BETTER_AUTH_SECRET"),
|
||||
database: adapter,
|
||||
trustedOrigins: [stripTrailingSlash(options.appUrl), stripTrailingSlash(options.apiUrl)],
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60,
|
||||
strategy: "compact",
|
||||
},
|
||||
},
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: requireEnv("GITHUB_CLIENT_ID"),
|
||||
clientSecret: requireEnv("GITHUB_CLIENT_SECRET"),
|
||||
scope: ["read:org", "repo"],
|
||||
redirectURI: process.env.GITHUB_REDIRECT_URI || undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
betterAuthService = {
|
||||
auth,
|
||||
|
||||
async resolveSession(headers: Headers) {
|
||||
return (await auth.api.getSession({ headers })) ?? null;
|
||||
},
|
||||
|
||||
async signOut(headers: Headers) {
|
||||
return await callAuthEndpoint(auth, `${stripTrailingSlash(process.env.BETTER_AUTH_URL ?? options.apiUrl)}${AUTH_BASE_PATH}/sign-out`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
},
|
||||
|
||||
async getAuthState(sessionId: string) {
|
||||
const workspace = await appWorkspace();
|
||||
const route = await workspace.authFindSessionIndex({ sessionId });
|
||||
if (!route?.userId) {
|
||||
return null;
|
||||
}
|
||||
const userActor = await getAuthUser(route.userId);
|
||||
return await userActor.getAppAuthState({ sessionId });
|
||||
},
|
||||
|
||||
async upsertUserProfile(userId: string, patch: Record<string, unknown>) {
|
||||
const userActor = await getAuthUser(userId);
|
||||
return await userActor.upsertUserProfile({ userId, patch });
|
||||
},
|
||||
|
||||
async setActiveOrganization(sessionId: string, activeOrganizationId: string | null) {
|
||||
const authState = await this.getAuthState(sessionId);
|
||||
if (!authState?.user?.id) {
|
||||
throw new Error(`Unknown auth session ${sessionId}`);
|
||||
}
|
||||
const userActor = await getAuthUser(authState.user.id);
|
||||
return await userActor.upsertSessionState({ sessionId, activeOrganizationId });
|
||||
},
|
||||
|
||||
async getAccessTokenForSession(sessionId: string) {
|
||||
// Read the GitHub access token directly from the account record stored in the
|
||||
// auth user actor. Better Auth's internal /get-access-token endpoint requires
|
||||
// session middleware resolution which fails for server-side internal calls (403),
|
||||
// so we bypass it and read the stored token from our adapter layer directly.
|
||||
const authState = await this.getAuthState(sessionId);
|
||||
if (!authState?.user?.id || !authState?.accounts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const githubAccount = authState.accounts.find((account: any) => account.providerId === "github");
|
||||
if (!githubAccount?.accessToken) {
|
||||
logger.warn({ sessionId, userId: authState.user.id }, "get_access_token_no_github_account");
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: githubAccount.accessToken,
|
||||
scopes: githubAccount.scope ? githubAccount.scope.split(/[, ]+/) : [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return betterAuthService;
|
||||
}
|
||||
|
||||
export function getBetterAuthService(): BetterAuthService {
|
||||
if (!betterAuthService) {
|
||||
throw new Error("BetterAuth service is not initialized");
|
||||
}
|
||||
return betterAuthService;
|
||||
}
|
||||
|
|
@ -38,14 +38,6 @@ import { sandboxInstanceKey, workspaceKey } from "./keys.js";
|
|||
|
||||
export type TaskAction = "push" | "sync" | "merge" | "archive" | "kill";
|
||||
|
||||
type RivetMetadataResponse = {
|
||||
runtime?: string;
|
||||
actorNames?: Record<string, unknown>;
|
||||
clientEndpoint?: string;
|
||||
clientNamespace?: string;
|
||||
clientToken?: string;
|
||||
};
|
||||
|
||||
export interface SandboxSessionRecord {
|
||||
id: string;
|
||||
agent: string;
|
||||
|
|
@ -138,16 +130,9 @@ export interface BackendClientOptions {
|
|||
mode?: "remote" | "mock";
|
||||
}
|
||||
|
||||
export interface BackendMetadata {
|
||||
runtime?: string;
|
||||
actorNames?: Record<string, unknown>;
|
||||
clientEndpoint?: string;
|
||||
clientNamespace?: string;
|
||||
clientToken?: string;
|
||||
}
|
||||
|
||||
export interface BackendClient {
|
||||
getAppSnapshot(): Promise<FoundryAppSnapshot>;
|
||||
subscribeApp(listener: () => void): () => void;
|
||||
signInWithGithub(): Promise<void>;
|
||||
signOutApp(): Promise<FoundryAppSnapshot>;
|
||||
skipAppStarterRepo(): Promise<FoundryAppSnapshot>;
|
||||
|
|
@ -295,118 +280,6 @@ function deriveBackendEndpoints(endpoint: string): { appEndpoint: string; rivetE
|
|||
};
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const h = hostname.toLowerCase();
|
||||
return h === "127.0.0.1" || h === "localhost" || h === "0.0.0.0" || h === "::1";
|
||||
}
|
||||
|
||||
function rewriteLoopbackClientEndpoint(clientEndpoint: string, fallbackOrigin: string): string {
|
||||
const clientUrl = new URL(clientEndpoint);
|
||||
if (!isLoopbackHost(clientUrl.hostname)) {
|
||||
return clientUrl.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
const originUrl = new URL(fallbackOrigin);
|
||||
// Keep the manager port from clientEndpoint; only rewrite host/protocol to match the origin.
|
||||
clientUrl.hostname = originUrl.hostname;
|
||||
clientUrl.protocol = originUrl.protocol;
|
||||
return clientUrl.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
async function fetchJsonWithTimeout(url: string, timeoutMs: number): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
throw new Error(`request failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return (await res.json()) as unknown;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMetadataWithRetry(
|
||||
endpoint: string,
|
||||
namespace: string | undefined,
|
||||
opts: { timeoutMs: number; requestTimeoutMs: number },
|
||||
): Promise<RivetMetadataResponse> {
|
||||
const base = new URL(endpoint);
|
||||
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
|
||||
if (namespace) {
|
||||
base.searchParams.set("namespace", namespace);
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
let delayMs = 250;
|
||||
// Keep this bounded: callers (UI/CLI) should not hang forever if the backend is down.
|
||||
for (;;) {
|
||||
try {
|
||||
const json = await fetchJsonWithTimeout(base.toString(), opts.requestTimeoutMs);
|
||||
if (!json || typeof json !== "object") return {};
|
||||
const data = json as Record<string, unknown>;
|
||||
return {
|
||||
runtime: typeof data.runtime === "string" ? data.runtime : undefined,
|
||||
actorNames: data.actorNames && typeof data.actorNames === "object" ? (data.actorNames as Record<string, unknown>) : undefined,
|
||||
clientEndpoint: typeof data.clientEndpoint === "string" ? data.clientEndpoint : undefined,
|
||||
clientNamespace: typeof data.clientNamespace === "string" ? data.clientNamespace : undefined,
|
||||
clientToken: typeof data.clientToken === "string" ? data.clientToken : undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
if (Date.now() - start > opts.timeoutMs) {
|
||||
throw err;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
delayMs = Math.min(delayMs * 2, 2_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readBackendMetadata(input: { endpoint: string; namespace?: string; timeoutMs?: number }): Promise<BackendMetadata> {
|
||||
const base = new URL(input.endpoint);
|
||||
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
|
||||
if (input.namespace) {
|
||||
base.searchParams.set("namespace", input.namespace);
|
||||
}
|
||||
|
||||
const json = await fetchJsonWithTimeout(base.toString(), input.timeoutMs ?? 4_000);
|
||||
if (!json || typeof json !== "object") {
|
||||
return {};
|
||||
}
|
||||
const data = json as Record<string, unknown>;
|
||||
return {
|
||||
runtime: typeof data.runtime === "string" ? data.runtime : undefined,
|
||||
actorNames: data.actorNames && typeof data.actorNames === "object" ? (data.actorNames as Record<string, unknown>) : undefined,
|
||||
clientEndpoint: typeof data.clientEndpoint === "string" ? data.clientEndpoint : undefined,
|
||||
clientNamespace: typeof data.clientNamespace === "string" ? data.clientNamespace : undefined,
|
||||
clientToken: typeof data.clientToken === "string" ? data.clientToken : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkBackendHealth(input: { endpoint: string; namespace?: string; timeoutMs?: number }): Promise<boolean> {
|
||||
try {
|
||||
const metadata = await readBackendMetadata(input);
|
||||
return metadata.runtime === "rivetkit" && Boolean(metadata.actorNames);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function probeMetadataEndpoint(endpoint: string, namespace: string | undefined, timeoutMs: number): Promise<boolean> {
|
||||
try {
|
||||
const base = new URL(endpoint);
|
||||
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
|
||||
if (namespace) {
|
||||
base.searchParams.set("namespace", namespace);
|
||||
}
|
||||
await fetchJsonWithTimeout(base.toString(), timeoutMs);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createBackendClient(options: BackendClientOptions): BackendClient {
|
||||
if (options.mode === "mock") {
|
||||
return createMockBackendClient(options.defaultWorkspaceId);
|
||||
|
|
@ -415,8 +288,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
const endpoints = deriveBackendEndpoints(options.endpoint);
|
||||
const rivetApiEndpoint = endpoints.rivetEndpoint;
|
||||
const appApiEndpoint = endpoints.appEndpoint;
|
||||
let clientPromise: Promise<RivetClient> | null = null;
|
||||
let appSessionId = typeof window !== "undefined" ? window.localStorage.getItem("sandbox-agent-foundry:remote-app-session") : null;
|
||||
const client = createClient({ endpoint: rivetApiEndpoint }) as unknown as RivetClient;
|
||||
const workbenchSubscriptions = new Map<
|
||||
string,
|
||||
{
|
||||
|
|
@ -431,34 +303,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
disposeConnPromise: Promise<(() => Promise<void>) | null> | null;
|
||||
}
|
||||
>();
|
||||
|
||||
const persistAppSessionId = (nextSessionId: string | null): void => {
|
||||
appSessionId = nextSessionId;
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (nextSessionId) {
|
||||
window.localStorage.setItem("sandbox-agent-foundry:remote-app-session", nextSessionId);
|
||||
} else {
|
||||
window.localStorage.removeItem("sandbox-agent-foundry:remote-app-session");
|
||||
}
|
||||
const appSubscriptions = {
|
||||
listeners: new Set<() => void>(),
|
||||
disposeConnPromise: null as Promise<(() => Promise<void>) | null> | null,
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const url = new URL(window.location.href);
|
||||
const sessionFromUrl = url.searchParams.get("foundrySession");
|
||||
if (sessionFromUrl) {
|
||||
persistAppSessionId(sessionFromUrl);
|
||||
url.searchParams.delete("foundrySession");
|
||||
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
}
|
||||
|
||||
const appRequest = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (appSessionId) {
|
||||
headers.set("x-foundry-session", appSessionId);
|
||||
}
|
||||
if (init?.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
|
@ -468,10 +319,6 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
const nextSessionId = res.headers.get("x-foundry-session");
|
||||
if (nextSessionId) {
|
||||
persistAppSessionId(nextSessionId);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`app request failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
|
@ -485,51 +332,12 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
}
|
||||
};
|
||||
|
||||
const getClient = async (): Promise<RivetClient> => {
|
||||
if (clientPromise) {
|
||||
return clientPromise;
|
||||
}
|
||||
|
||||
clientPromise = (async () => {
|
||||
// Use the serverless /metadata endpoint to discover the manager endpoint.
|
||||
// If the server reports a loopback clientEndpoint (127.0.0.1), rewrite to the same host
|
||||
// as the configured endpoint so remote browsers/clients can connect.
|
||||
const configured = new URL(rivetApiEndpoint);
|
||||
const configuredOrigin = `${configured.protocol}//${configured.host}`;
|
||||
|
||||
const initialNamespace = undefined;
|
||||
const metadata = await fetchMetadataWithRetry(rivetApiEndpoint, initialNamespace, {
|
||||
timeoutMs: 30_000,
|
||||
requestTimeoutMs: 8_000,
|
||||
});
|
||||
|
||||
// Candidate endpoint: manager endpoint if provided, otherwise stick to the configured endpoint.
|
||||
const candidateEndpoint = metadata.clientEndpoint ? rewriteLoopbackClientEndpoint(metadata.clientEndpoint, configuredOrigin) : rivetApiEndpoint;
|
||||
|
||||
// If the manager port isn't reachable from this client (common behind reverse proxies),
|
||||
// fall back to the configured serverless endpoint to avoid hanging requests.
|
||||
const shouldUseCandidate = metadata.clientEndpoint ? await probeMetadataEndpoint(candidateEndpoint, metadata.clientNamespace, 1_500) : true;
|
||||
const resolvedEndpoint = shouldUseCandidate ? candidateEndpoint : rivetApiEndpoint;
|
||||
|
||||
return createClient({
|
||||
endpoint: resolvedEndpoint,
|
||||
namespace: metadata.clientNamespace,
|
||||
token: metadata.clientToken,
|
||||
// Prevent rivetkit from overriding back to a loopback endpoint (or to an unreachable manager).
|
||||
disableMetadataLookup: true,
|
||||
}) as unknown as RivetClient;
|
||||
})();
|
||||
|
||||
return clientPromise;
|
||||
};
|
||||
|
||||
const workspace = async (workspaceId: string): Promise<WorkspaceHandle> =>
|
||||
(await getClient()).workspace.getOrCreate(workspaceKey(workspaceId), {
|
||||
client.workspace.getOrCreate(workspaceKey(workspaceId), {
|
||||
createWithInput: workspaceId,
|
||||
});
|
||||
|
||||
const sandboxByKey = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<SandboxInstanceHandle> => {
|
||||
const client = await getClient();
|
||||
return (client as any).sandboxInstance.get(sandboxInstanceKey(workspaceId, providerId, sandboxId));
|
||||
};
|
||||
|
||||
|
|
@ -557,7 +365,6 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
(sb as any).sandboxActorId.length > 0,
|
||||
) as { sandboxActorId?: string } | undefined;
|
||||
if (sandbox?.sandboxActorId) {
|
||||
const client = await getClient();
|
||||
return (client as any).sandboxInstance.getForId(sandbox.sandboxActorId);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -698,17 +505,62 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
};
|
||||
};
|
||||
|
||||
const subscribeApp = (listener: () => void): (() => void) => {
|
||||
appSubscriptions.listeners.add(listener);
|
||||
|
||||
if (!appSubscriptions.disposeConnPromise) {
|
||||
appSubscriptions.disposeConnPromise = (async () => {
|
||||
const handle = await workspace("app");
|
||||
const conn = (handle as any).connect();
|
||||
const unsubscribeEvent = conn.on("appUpdated", () => {
|
||||
for (const currentListener of [...appSubscriptions.listeners]) {
|
||||
currentListener();
|
||||
}
|
||||
});
|
||||
const unsubscribeError = conn.onError(() => {});
|
||||
return async () => {
|
||||
unsubscribeEvent();
|
||||
unsubscribeError();
|
||||
await conn.dispose();
|
||||
};
|
||||
})().catch(() => null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
appSubscriptions.listeners.delete(listener);
|
||||
if (appSubscriptions.listeners.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void appSubscriptions.disposeConnPromise?.then(async (disposeConn) => {
|
||||
await disposeConn?.();
|
||||
});
|
||||
appSubscriptions.disposeConnPromise = null;
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
async getAppSnapshot(): Promise<FoundryAppSnapshot> {
|
||||
return await appRequest<FoundryAppSnapshot>("/app/snapshot");
|
||||
},
|
||||
|
||||
subscribeApp(listener: () => void): () => void {
|
||||
return subscribeApp(listener);
|
||||
},
|
||||
|
||||
async signInWithGithub(): Promise<void> {
|
||||
const callbackURL = typeof window !== "undefined" ? `${window.location.origin}/organizations` : `${appApiEndpoint.replace(/\/$/, "")}/organizations`;
|
||||
const response = await appRequest<{ url: string; redirect?: boolean }>("/auth/sign-in/social", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
provider: "github",
|
||||
callbackURL,
|
||||
disableRedirect: true,
|
||||
}),
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign(`${appApiEndpoint}/auth/github/start`);
|
||||
return;
|
||||
window.location.assign(response.url);
|
||||
}
|
||||
await redirectTo("/auth/github/start");
|
||||
},
|
||||
|
||||
async signOutApp(): Promise<FoundryAppSnapshot> {
|
||||
|
|
|
|||
|
|
@ -192,6 +192,10 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
|||
return unsupportedAppSnapshot();
|
||||
},
|
||||
|
||||
subscribeApp(_listener: () => void): () => void {
|
||||
return () => {};
|
||||
},
|
||||
|
||||
async signInWithGithub(): Promise<void> {
|
||||
notSupported("signInWithGithub");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class RemoteFoundryAppStore implements FoundryAppClient {
|
|||
};
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
private syncPollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private unsubscribeApp: (() => void) | null = null;
|
||||
|
||||
constructor(options: RemoteFoundryAppClientOptions) {
|
||||
this.backend = options.backend;
|
||||
|
|
@ -37,9 +37,13 @@ class RemoteFoundryAppStore implements FoundryAppClient {
|
|||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
void this.refresh();
|
||||
this.ensureStarted();
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
if (this.listeners.size === 0 && this.unsubscribeApp) {
|
||||
this.unsubscribeApp();
|
||||
this.unsubscribeApp = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +70,6 @@ class RemoteFoundryAppStore implements FoundryAppClient {
|
|||
async selectOrganization(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.selectAppOrganization(organizationId);
|
||||
this.notify();
|
||||
this.scheduleSyncPollingIfNeeded();
|
||||
}
|
||||
|
||||
async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> {
|
||||
|
|
@ -77,7 +80,6 @@ class RemoteFoundryAppStore implements FoundryAppClient {
|
|||
async triggerGithubSync(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.triggerAppRepoImport(organizationId);
|
||||
this.notify();
|
||||
this.scheduleSyncPollingIfNeeded();
|
||||
}
|
||||
|
||||
async completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void> {
|
||||
|
|
@ -107,20 +109,13 @@ class RemoteFoundryAppStore implements FoundryAppClient {
|
|||
this.notify();
|
||||
}
|
||||
|
||||
private scheduleSyncPollingIfNeeded(): void {
|
||||
if (this.syncPollTimeout) {
|
||||
clearTimeout(this.syncPollTimeout);
|
||||
this.syncPollTimeout = null;
|
||||
private ensureStarted(): void {
|
||||
if (!this.unsubscribeApp) {
|
||||
this.unsubscribeApp = this.backend.subscribeApp(() => {
|
||||
void this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.snapshot.organizations.some((organization) => organization.github.syncStatus === "syncing")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncPollTimeout = setTimeout(() => {
|
||||
this.syncPollTimeout = null;
|
||||
void this.refresh();
|
||||
}, 500);
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
|
|
@ -132,7 +127,6 @@ class RemoteFoundryAppStore implements FoundryAppClient {
|
|||
this.refreshPromise = (async () => {
|
||||
this.snapshot = await this.backend.getAppSnapshot();
|
||||
this.notify();
|
||||
this.scheduleSyncPollingIfNeeded();
|
||||
})().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -727,7 +727,7 @@ const RightRail = memo(function RightRail({
|
|||
}, [clampTerminalHeight]);
|
||||
|
||||
const startResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
(event: ReactPointerEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const startY = event.clientY;
|
||||
|
|
@ -914,7 +914,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
const activeOrg = activeMockOrganization(appSnapshot);
|
||||
const navigateToUsage = useCallback(() => {
|
||||
if (activeOrg) {
|
||||
void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } });
|
||||
void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } as never });
|
||||
}
|
||||
}, [activeOrg, navigate]);
|
||||
const [projectOrder, setProjectOrder] = useState<string[] | null>(null);
|
||||
|
|
@ -1283,7 +1283,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
const onDragMouseDown = useCallback((event: ReactPointerEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
// Tauri v2 IPC: invoke start_dragging on the webview window
|
||||
const ipc = (window as Record<string, unknown>).__TAURI_INTERNALS__ as { invoke: (cmd: string, args?: unknown) => Promise<unknown> } | undefined;
|
||||
const ipc = (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ as
|
||||
| {
|
||||
invoke: (cmd: string, args?: unknown) => Promise<unknown>;
|
||||
}
|
||||
| undefined;
|
||||
if (ipc?.invoke) {
|
||||
ipc.invoke("plugin:window|start_dragging").catch(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,9 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
setProcessTabs((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(d.fromIdx, 1);
|
||||
if (!moved) {
|
||||
return prev;
|
||||
}
|
||||
next.splice(d.overIdx!, 0, moved);
|
||||
return next;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,7 +56,11 @@ function DesktopDragRegion() {
|
|||
const isDesktop = !!import.meta.env.VITE_DESKTOP;
|
||||
const onDragMouseDown = useCallback((event: React.PointerEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
const ipc = (window as Record<string, unknown>).__TAURI_INTERNALS__ as { invoke: (cmd: string, args?: unknown) => Promise<unknown> } | undefined;
|
||||
const ipc = (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ as
|
||||
| {
|
||||
invoke: (cmd: string, args?: unknown) => Promise<unknown>;
|
||||
}
|
||||
| undefined;
|
||||
if (ipc?.invoke) {
|
||||
ipc.invoke("plugin:window|start_dragging").catch(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,6 +172,23 @@ export const RepoOverviewSchema = z.object({
|
|||
baseRef: z.string().nullable(),
|
||||
stackAvailable: z.boolean(),
|
||||
fetchedAt: z.number().int(),
|
||||
branchSyncAt: z.number().int().nullable(),
|
||||
prSyncAt: z.number().int().nullable(),
|
||||
branchSyncStatus: z.enum(["pending", "syncing", "synced", "error"]),
|
||||
prSyncStatus: z.enum(["pending", "syncing", "synced", "error"]),
|
||||
repoActionJobs: z.array(
|
||||
z.object({
|
||||
jobId: z.string().min(1),
|
||||
action: z.enum(["sync_repo", "restack_repo", "restack_subtree", "rebase_branch", "reparent_branch"]),
|
||||
branchName: z.string().nullable(),
|
||||
parentBranch: z.string().nullable(),
|
||||
status: z.enum(["queued", "running", "completed", "error"]),
|
||||
message: z.string().min(1),
|
||||
createdAt: z.number().int(),
|
||||
updatedAt: z.number().int(),
|
||||
completedAt: z.number().int().nullable(),
|
||||
}),
|
||||
),
|
||||
branches: z.array(RepoBranchRecordSchema),
|
||||
});
|
||||
export type RepoOverview = z.infer<typeof RepoOverviewSchema>;
|
||||
|
|
@ -189,8 +206,10 @@ export const RepoStackActionInputSchema = z.object({
|
|||
export type RepoStackActionInput = z.infer<typeof RepoStackActionInputSchema>;
|
||||
|
||||
export const RepoStackActionResultSchema = z.object({
|
||||
jobId: z.string().min(1).nullable().optional(),
|
||||
action: RepoStackActionSchema,
|
||||
executed: z.boolean(),
|
||||
status: z.enum(["queued", "running", "completed", "error"]).optional(),
|
||||
message: z.string().min(1),
|
||||
at: z.number().int(),
|
||||
});
|
||||
|
|
|
|||
52
justfile
52
justfile
|
|
@ -127,75 +127,35 @@ foundry-check:
|
|||
foundry-dev:
|
||||
pnpm install
|
||||
mkdir -p foundry/.foundry/logs
|
||||
HF_DOCKER_UID="$(id -u)" HF_DOCKER_GID="$(id -g)" docker compose -f foundry/compose.dev.yaml up --build --force-recreate -d
|
||||
HF_DOCKER_UID="$(id -u)" HF_DOCKER_GID="$(id -g)" docker compose --env-file .env -f foundry/compose.dev.yaml up --build --force-recreate -d
|
||||
|
||||
[group('foundry')]
|
||||
foundry-preview:
|
||||
pnpm install
|
||||
mkdir -p foundry/.foundry/logs
|
||||
HF_DOCKER_UID="$(id -u)" HF_DOCKER_GID="$(id -g)" docker compose -f foundry/compose.preview.yaml up --build --force-recreate -d
|
||||
|
||||
[group('foundry')]
|
||||
foundry-frontend-dev host='127.0.0.1' port='4173' backend='http://127.0.0.1:7741/api/rivet':
|
||||
pnpm install
|
||||
VITE_HF_BACKEND_ENDPOINT="{{backend}}" pnpm --filter @sandbox-agent/foundry-frontend dev -- --host {{host}} --port {{port}}
|
||||
|
||||
[group('foundry')]
|
||||
foundry-dev-mock host='127.0.0.1' port='4173':
|
||||
pnpm install
|
||||
FOUNDRY_FRONTEND_CLIENT_MODE=mock pnpm --filter @sandbox-agent/foundry-frontend dev -- --host {{host}} --port {{port}}
|
||||
|
||||
[group('foundry')]
|
||||
foundry-dev-turbo:
|
||||
pnpm exec turbo run dev --parallel --filter=@sandbox-agent/foundry-*
|
||||
HF_DOCKER_UID="$(id -u)" HF_DOCKER_GID="$(id -g)" docker compose --env-file .env -f foundry/compose.preview.yaml up --build --force-recreate -d
|
||||
|
||||
[group('foundry')]
|
||||
foundry-dev-down:
|
||||
docker compose -f foundry/compose.dev.yaml down
|
||||
docker compose --env-file .env -f foundry/compose.dev.yaml down
|
||||
|
||||
[group('foundry')]
|
||||
foundry-dev-logs:
|
||||
docker compose -f foundry/compose.dev.yaml logs -f --tail=200
|
||||
docker compose --env-file .env -f foundry/compose.dev.yaml logs -f --tail=200
|
||||
|
||||
[group('foundry')]
|
||||
foundry-preview-down:
|
||||
docker compose -f foundry/compose.preview.yaml down
|
||||
docker compose --env-file .env -f foundry/compose.preview.yaml down
|
||||
|
||||
[group('foundry')]
|
||||
foundry-preview-logs:
|
||||
docker compose -f foundry/compose.preview.yaml logs -f --tail=200
|
||||
docker compose --env-file .env -f foundry/compose.preview.yaml logs -f --tail=200
|
||||
|
||||
[group('foundry')]
|
||||
foundry-format:
|
||||
prettier --write foundry
|
||||
|
||||
[group('foundry')]
|
||||
foundry-backend-start host='127.0.0.1' port='7741':
|
||||
pnpm install
|
||||
pnpm --filter @sandbox-agent/foundry-backend build
|
||||
pnpm --filter @sandbox-agent/foundry-backend start -- --host {{host}} --port {{port}}
|
||||
|
||||
[group('foundry')]
|
||||
foundry-hf *ARGS:
|
||||
@echo "CLI package is disabled in this repo; use frontend workflows instead." >&2
|
||||
@exit 1
|
||||
|
||||
[group('foundry')]
|
||||
foundry-docker-build tag='foundry:local':
|
||||
docker build -f foundry/docker/backend.Dockerfile -t {{tag}} .
|
||||
|
||||
[group('foundry')]
|
||||
foundry-desktop-dev:
|
||||
pnpm --filter @sandbox-agent/foundry-desktop dev
|
||||
|
||||
[group('foundry')]
|
||||
foundry-desktop-build:
|
||||
pnpm --filter @sandbox-agent/foundry-desktop build:all
|
||||
|
||||
[group('foundry')]
|
||||
foundry-railway-up:
|
||||
npx -y @railway/cli up --detach
|
||||
|
||||
[group('foundry')]
|
||||
foundry-railway-status:
|
||||
npx -y @railway/cli status --json
|
||||
|
|
|
|||
1015
pnpm-lock.yaml
generated
1015
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue