diff --git a/.gitignore b/.gitignore index 0bad302e..95f83b3e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,15 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .cache *.tsbuildinfo +# app generated files +apps/desktop/.svelte-kit +apps/desktop/.svelte_kit +apps/desktop/build +apps/desktop/src-tauri/target +apps/desktop/src-tauri/gen/ +apps/web/.svelte-kit +apps/web/.svelte_kit + # IntelliJ based IDEs .idea diff --git a/apps/cli/package.json b/apps/cli/package.json index 2c7cfc01..adc59141 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -47,23 +47,23 @@ "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", "@btca/shared": "workspace:*", + "@effect/platform-bun": "4.0.0-beta.20", "@inquirer/select": "^5.0.4", - "@opentui/core": "0.1.77", - "@opentui/react": "0.1.77", + "@opentui/core": "0.1.86", + "@opentui/react": "0.1.86", "@tmcp/adapter-zod": "^0.1.7", "@tmcp/transport-stdio": "^0.4.1", "@types/bun": "latest", - "@types/node": "22", + "@types/node": "22.19.15", "@types/react": "^19.2.13", "@typescript/native-preview": "^7.0.0-dev.20260109.1", "babel-plugin-react-compiler": "^1.0.0", "btca-server": "workspace:*", + "effect": "4.0.0-beta.20", "prettier": "^3.7.4", "react": "^19.2.4", "tmcp": "^1.19.2", - "web-tree-sitter": "0.25.10", - "zod": "^4.3.6", - "@effect/platform-bun": "4.0.0-beta.20", - "effect": "4.0.0-beta.20" + "web-tree-sitter": "0.26.6", + "zod": "^4.3.6" } } diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts index a44a5f6b..84caf6a0 100644 --- a/apps/cli/src/commands/init.ts +++ b/apps/cli/src/commands/init.ts @@ -113,7 +113,9 @@ async function fileExists(filePath: string): Promise { async function handleCliSetup(cwd: string, configPath: string, force?: boolean): Promise { if (await fileExists(configPath)) { if (!force) { - throw new Error(`${PROJECT_CONFIG_FILENAME} already exists. Use --force to overwrite.`); + throw new Error( + `${PROJECT_CONFIG_FILENAME} already exists at ${configPath}. Use --force to overwrite.` + ); } console.log(`\nOverwriting existing ${PROJECT_CONFIG_FILENAME}...`); } @@ -170,8 +172,11 @@ async function handleCliSetup(cwd: string, configPath: string, force?: boolean): } export const runInitCommand = (args: { force?: boolean }) => - Effect.tryPromise(async () => { - const cwd = process.cwd(); - const configPath = path.join(cwd, PROJECT_CONFIG_FILENAME); - await handleCliSetup(cwd, configPath, args.force); + Effect.tryPromise({ + try: async () => { + const cwd = process.cwd(); + const configPath = path.join(cwd, PROJECT_CONFIG_FILENAME); + await handleCliSetup(cwd, configPath, args.force); + }, + catch: (error) => error }); diff --git a/apps/cli/src/index.test.ts b/apps/cli/src/index.test.ts index a6e7284e..67429fb7 100644 --- a/apps/cli/src/index.test.ts +++ b/apps/cli/src/index.test.ts @@ -9,10 +9,15 @@ const CLI_DIR = fileURLToPath(new URL('..', import.meta.url)); const textFromProcessOutput = (value: Uint8Array | string | undefined) => typeof value === 'string' ? value : value ? new TextDecoder().decode(value) : ''; -const runCli = (argv: string[], timeout = 10_000, env?: Record) => { +const runCliAtCwd = ( + argv: string[], + cwd: string, + timeout = 10_000, + env?: Record +) => { const result = Bun.spawnSync({ - cmd: ['bun', 'run', 'src/index.ts', ...argv], - cwd: CLI_DIR, + cmd: ['bun', 'run', path.join(CLI_DIR, 'src/index.ts'), ...argv], + cwd, stdout: 'pipe', stderr: 'pipe', timeout, @@ -25,6 +30,9 @@ const runCli = (argv: string[], timeout = 10_000, env?: Record) }; }; +const runCli = (argv: string[], timeout = 10_000, env?: Record) => + runCliAtCwd(argv, CLI_DIR, timeout, env); + const withTempHome = async (run: (tempHome: string) => Promise): Promise => { const tempHome = mkdtempSync(path.join(tmpdir(), 'btca-cli-test-')); const originalHome = process.env.HOME; @@ -37,6 +45,15 @@ const withTempHome = async (run: (tempHome: string) => Promise): Promise(run: (tempDir: string) => Promise): Promise => { + const tempDir = mkdtempSync(path.join(tmpdir(), 'btca-cli-cwd-')); + try { + return await run(tempDir); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}; + const createStubServer = (handlers?: Partial Response>>) => { const requestPaths: string[] = []; const defaultHandlers: Record Response> = { @@ -104,6 +121,19 @@ describe('cli dispatch', () => { expect(result.output).toContain('Unknown subcommand "foo" for "btca telemetry"'); }); + test('preserves helpful error when init config already exists', async () => { + await withTempDir(async (tempDir) => { + const configPath = path.join(tempDir, 'btca.config.jsonc'); + await Bun.write(configPath, '{}'); + + const result = runCliAtCwd(['init'], tempDir); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('btca.config.jsonc already exists at '); + expect(result.output).toContain('btca.config.jsonc. Use --force to overwrite.'); + expect(result.output).not.toContain('An error occurred in Effect.tryPromise'); + }); + }); + test('forwards subcommand --server to resources command', async () => { const stub = createStubServer(); try { diff --git a/apps/sandbox/package.json b/apps/sandbox/package.json index e384e20a..28506879 100644 --- a/apps/sandbox/package.json +++ b/apps/sandbox/package.json @@ -38,6 +38,6 @@ "prettier": "^3.7.4" }, "dependencies": { - "@daytonaio/sdk": "^0.130.0" + "@daytonaio/sdk": "^0.149.0" } } diff --git a/apps/sandbox/src/shared.ts b/apps/sandbox/src/shared.ts index a25f2307..2d0c9614 100644 --- a/apps/sandbox/src/shared.ts +++ b/apps/sandbox/src/shared.ts @@ -1,2 +1,2 @@ // Snapshot name for btca sandbox -export const BTCA_SNAPSHOT_NAME = 'btca-app-sandbox-3'; +export const BTCA_SNAPSHOT_NAME = 'btca-app-sandbox-4'; diff --git a/apps/sandbox/src/snapshot.ts b/apps/sandbox/src/snapshot.ts index f9fdeb72..35db194f 100644 --- a/apps/sandbox/src/snapshot.ts +++ b/apps/sandbox/src/snapshot.ts @@ -38,8 +38,8 @@ async function main(): Promise { name: BTCA_SNAPSHOT_NAME, image, resources: { - cpu: 2, - memory: 4, + cpu: 1, + memory: 3, disk: 5 } }, diff --git a/apps/server/package.json b/apps/server/package.json index 44491580..548c7d2e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -59,8 +59,8 @@ "@effect/platform-bun": "4.0.0-beta.20", "ai": "^6.0.49", "effect": "4.0.0-beta.20", - "just-bash": "^2.7.0", - "opencode-ai": "^1.1.36", + "just-bash": "^2.12.4", + "opencode-ai": "^1.2.21", "vercel-minimax-ai-provider": "^0.0.2", "zod": "^3.25.76" } diff --git a/apps/server/src/agent/loop.ts b/apps/server/src/agent/loop.ts index d0d8d1b6..2ed057bc 100644 --- a/apps/server/src/agent/loop.ts +++ b/apps/server/src/agent/loop.ts @@ -35,6 +35,9 @@ export type AgentEvent = inputTokens?: number; outputTokens?: number; reasoningTokens?: number; + cachedTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; totalTokens?: number; }; } @@ -223,18 +226,29 @@ export const runAgentLoop = async (options: AgentLoopOptions): Promise { type: 'finish', finishReason: 'stop', usage: { - inputTokens: 1_000_000, + inputTokens: 750_000, outputTokens: 2_000_000, reasoningTokens: 250_000, + cachedTokens: 250_000, + cacheReadTokens: 200_000, + cacheWriteTokens: 50_000, totalTokens: 3_250_000 } } as const; @@ -84,7 +87,13 @@ describe('createSseStream', () => { lookup: async () => ({ source: 'models.dev' as const, modelKey: 'openai/gpt-4o-mini', - ratesUsdPerMTokens: { input: 1, output: 2, reasoning: 0.5 } + ratesUsdPerMTokens: { + input: 1, + output: 2, + reasoning: 0.5, + cacheRead: 0.25, + cacheWrite: 1.5 + } }) } }); @@ -97,9 +106,12 @@ describe('createSseStream', () => { if (doneEvent?.type !== 'done') throw new Error('missing done event'); - expect(doneEvent.usage?.inputTokens).toBe(1_000_000); + expect(doneEvent.usage?.inputTokens).toBe(750_000); expect(doneEvent.usage?.outputTokens).toBe(2_000_000); expect(doneEvent.usage?.reasoningTokens).toBe(250_000); + expect(doneEvent.usage?.cachedTokens).toBe(250_000); + expect(doneEvent.usage?.cacheReadTokens).toBe(200_000); + expect(doneEvent.usage?.cacheWriteTokens).toBe(50_000); expect(doneEvent.usage?.totalTokens).toBe(3_250_000); expect(typeof doneEvent.metrics?.timing?.totalMs).toBe('number'); @@ -113,8 +125,8 @@ describe('createSseStream', () => { expect(doneEvent.metrics?.pricing?.modelKey).toBe('openai/gpt-4o-mini'); expect(doneEvent.metrics?.pricing?.ratesUsdPerMTokens?.input).toBe(1); - // cost = (1.0 * 1) + (2.0 * 2) + (0.25 * 0.5) = 5.125 - expect(doneEvent.metrics?.pricing?.costUsd?.total).toBeCloseTo(5.125, 8); + // cost = (0.75 * 1) + (2.0 * 2) + (0.25 * 0.5) + (0.2 * 0.25) + (0.05 * 1.5) = 5 + expect(doneEvent.metrics?.pricing?.costUsd?.total).toBeCloseTo(5, 8); }); it('does not throw if the client cancels before an error is emitted', async () => { diff --git a/apps/server/src/stream/service.ts b/apps/server/src/stream/service.ts index 04fe5f63..9eed036a 100644 --- a/apps/server/src/stream/service.ts +++ b/apps/server/src/stream/service.ts @@ -193,6 +193,9 @@ export const createSseStream = (args: { inputTokens: event.usage?.inputTokens, outputTokens: event.usage?.outputTokens, reasoningTokens: event.usage?.reasoningTokens, + cachedTokens: event.usage?.cachedTokens, + cacheReadTokens: event.usage?.cacheReadTokens, + cacheWriteTokens: event.usage?.cacheWriteTokens, totalTokens: event.usage?.totalTokens } : undefined; @@ -233,8 +236,20 @@ export const createSseStream = (args: { const input = costFor(usage.inputTokens, rates.input); const output = costFor(usage.outputTokens, rates.output); const reasoning = costFor(usage.reasoningTokens, rates.reasoning); - const hasAnyCostPart = input != null || output != null || reasoning != null; - const total = (input ?? 0) + (output ?? 0) + (reasoning ?? 0); + const cacheRead = costFor(usage.cacheReadTokens, rates.cacheRead); + const cacheWrite = costFor(usage.cacheWriteTokens, rates.cacheWrite); + const hasAnyCostPart = + input != null || + output != null || + reasoning != null || + cacheRead != null || + cacheWrite != null; + const total = + (input ?? 0) + + (output ?? 0) + + (reasoning ?? 0) + + (cacheRead ?? 0) + + (cacheWrite ?? 0); return { source: 'models.dev' as const, diff --git a/apps/server/src/stream/types.ts b/apps/server/src/stream/types.ts index 32553f37..8bd5e7d7 100644 --- a/apps/server/src/stream/types.ts +++ b/apps/server/src/stream/types.ts @@ -70,6 +70,9 @@ export const BtcaStreamUsageSchema = z.object({ inputTokens: z.number().optional(), outputTokens: z.number().optional(), reasoningTokens: z.number().optional(), + cachedTokens: z.number().optional(), + cacheReadTokens: z.number().optional(), + cacheWriteTokens: z.number().optional(), totalTokens: z.number().optional() }); diff --git a/apps/web/@useautumn-sdk.d.ts b/apps/web/@useautumn-sdk.d.ts new file mode 100644 index 00000000..e3d54622 --- /dev/null +++ b/apps/web/@useautumn-sdk.d.ts @@ -0,0 +1,19 @@ +// AUTO-GENERATED by atmn pull +// DO NOT EDIT MANUALLY + +declare module '@useautumn/sdk' { + // Features + export const sandbox_hours: Feature; + export const tokens_out: Feature; + export const tokens_in: Feature; + export const chat_messages: Feature; + export const ai_budget: Feature; + + // Plans + export const free_plan: Plan; + export const btca_pro: Plan; + + // Base types + export type Feature = import('./autumn.config').Feature; + export type Plan = import('./autumn.config').Plan; +} diff --git a/apps/web/autumn.config.ts b/apps/web/autumn.config.ts index 7d5ace40..d151ebbe 100644 --- a/apps/web/autumn.config.ts +++ b/apps/web/autumn.config.ts @@ -1,68 +1,73 @@ -import { feature, product, featureItem, priceItem } from 'atmn'; +import { feature, item, plan } from 'atmn'; + +import { PRO_AI_BUDGET_MICROS } from './src/lib/billing/aiBudget.ts'; // Features -export const sandboxHours = feature({ +export const sandbox_hours = feature({ id: 'sandbox_hours', name: 'Sandbox Hours', - type: 'single_use' + type: 'metered', + consumable: true }); -export const tokensOut = feature({ +export const tokens_out = feature({ id: 'tokens_out', name: 'Tokens Out', - type: 'single_use' + type: 'metered', + consumable: true }); -export const tokensIn = feature({ +export const tokens_in = feature({ id: 'tokens_in', name: 'Tokens In', - type: 'single_use' + type: 'metered', + consumable: true }); -export const chatMessages = feature({ +export const chat_messages = feature({ id: 'chat_messages', name: 'Chat Messages', - type: 'single_use' + type: 'metered', + consumable: true +}); + +export const ai_budget = feature({ + id: 'ai_budget', + name: 'AI Budget', + type: 'metered', + consumable: true }); -// Products -export const freePlan = product({ +// Plans +export const free_plan = plan({ id: 'free_plan', name: 'Free Plan', - is_default: true, + autoEnable: true, items: [ - featureItem({ - feature_id: chatMessages.id, - included_usage: 5 + item({ + featureId: chat_messages.id, + included: 5, + reset: { + interval: 'one_off' + } }) ] }); -export const btcaPro = product({ +export const btca_pro = plan({ id: 'btca_pro', name: 'Pro Plan', + price: { + amount: 8, + interval: 'month' + }, items: [ - priceItem({ - price: 8, - interval: 'month' - }), - - featureItem({ - feature_id: sandboxHours.id, - included_usage: 6, - interval: 'month' - }), - - featureItem({ - feature_id: tokensIn.id, - included_usage: 1500000, - interval: 'month' - }), - - featureItem({ - feature_id: tokensOut.id, - included_usage: 300000, - interval: 'month' + item({ + featureId: ai_budget.id, + included: PRO_AI_BUDGET_MICROS, + reset: { + interval: 'month' + } }) ] }); diff --git a/apps/web/package.json b/apps/web/package.json index ab6dd82c..3fb896fe 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,12 +23,12 @@ "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@typescript/native-preview": "^7.0.0-dev.20260211.1", - "atmn": "^0.0.30", + "atmn": "^1.1.2", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.4.1", "prettier-plugin-tailwindcss": "^0.7.2", "svelte": "^5.50.2", - "svelte-check": "^4.3.6", + "svelte-check": "^4.4.5", "tailwindcss": "^4.1.18", "turbo": "^2.8.7", "vite": "^7.3.1" @@ -40,14 +40,14 @@ "@clerk/clerk-js": "^5.122.1", "@clerk/types": "^4.101.14", "@convex-dev/migrations": "^0.3.1", - "@daytonaio/sdk": "^0.130.0", - "@lucide/svelte": "^0.562.0", + "@daytonaio/sdk": "^0.149.0", + "@lucide/svelte": "^0.577.0", "@shikijs/langs": "^3.22.0", "@shikijs/themes": "^3.22.0", "@tmcp/adapter-zod": "^0.1.7", "@tmcp/transport-http": "^0.8.4", "ai": "^6.0.81", - "autumn-js": "^0.1.75", + "autumn-js": "^0.1.82", "better-result": "^2.7.0", "btca-sandbox": "workspace:*", "convex": "^1.31.7", @@ -55,7 +55,7 @@ "isomorphic-dompurify": "^2.36.0", "marked": "^17.0.2", "nanoid": "^5.1.6", - "posthog-js": "^1.345.5", + "posthog-js": "^1.359.1", "posthog-node": "^5.24.15", "shiki": "^3.22.0", "tmcp": "^1.19.2", diff --git a/apps/web/src/convex/_generated/api.d.ts b/apps/web/src/convex/_generated/api.d.ts index 6d941959..3bae7cfd 100644 --- a/apps/web/src/convex/_generated/api.d.ts +++ b/apps/web/src/convex/_generated/api.d.ts @@ -8,72 +8,80 @@ * @module */ -import type * as analytics from '../analytics.js'; -import type * as analyticsEvents from '../analyticsEvents.js'; -import type * as apiHelpers from '../apiHelpers.js'; -import type * as authHelpers from '../authHelpers.js'; -import type * as clerkApiKeys from '../clerkApiKeys.js'; -import type * as clerkApiKeysQueries from '../clerkApiKeysQueries.js'; -import type * as crons from '../crons.js'; -import type * as githubAuth from '../githubAuth.js'; -import type * as githubConnections from '../githubConnections.js'; -import type * as http from '../http.js'; -import type * as instances_actions from '../instances/actions.js'; -import type * as instances_mutations from '../instances/mutations.js'; -import type * as instances_queries from '../instances/queries.js'; -import type * as mcp from '../mcp.js'; -import type * as mcp_resourceContract from '../mcp/resourceContract.js'; -import type * as mcpInternal from '../mcpInternal.js'; -import type * as mcpQuestions from '../mcpQuestions.js'; -import type * as messages from '../messages.js'; -import type * as migrations from '../migrations.js'; -import type * as privateWrappers from '../privateWrappers.js'; -import type * as projects from '../projects.js'; -import type * as resourceActions from '../resourceActions.js'; -import type * as resources from '../resources.js'; -import type * as scheduled_queries from '../scheduled/queries.js'; -import type * as scheduled_updates from '../scheduled/updates.js'; -import type * as scheduled_versionCheck from '../scheduled/versionCheck.js'; -import type * as streamSessions from '../streamSessions.js'; -import type * as threadTitle from '../threadTitle.js'; -import type * as threads from '../threads.js'; -import type * as usage from '../usage.js'; -import type * as users from '../users.js'; +import type * as analytics from "../analytics.js"; +import type * as analyticsEvents from "../analyticsEvents.js"; +import type * as apiHelpers from "../apiHelpers.js"; +import type * as authHelpers from "../authHelpers.js"; +import type * as clerkApiKeys from "../clerkApiKeys.js"; +import type * as clerkApiKeysQueries from "../clerkApiKeysQueries.js"; +import type * as crons from "../crons.js"; +import type * as githubApp from "../githubApp.js"; +import type * as githubAuth from "../githubAuth.js"; +import type * as githubConnections from "../githubConnections.js"; +import type * as http from "../http.js"; +import type * as instances_actions from "../instances/actions.js"; +import type * as instances_mutations from "../instances/mutations.js"; +import type * as instances_queries from "../instances/queries.js"; +import type * as mcp from "../mcp.js"; +import type * as mcp_resourceContract from "../mcp/resourceContract.js"; +import type * as mcpInternal from "../mcpInternal.js"; +import type * as mcpQuestions from "../mcpQuestions.js"; +import type * as messages from "../messages.js"; +import type * as migrations from "../migrations.js"; +import type * as privateWrappers from "../privateWrappers.js"; +import type * as projects from "../projects.js"; +import type * as resourceActions from "../resourceActions.js"; +import type * as resources from "../resources.js"; +import type * as runtimeConfigLock from "../runtimeConfigLock.js"; +import type * as scheduled_queries from "../scheduled/queries.js"; +import type * as scheduled_updates from "../scheduled/updates.js"; +import type * as scheduled_versionCheck from "../scheduled/versionCheck.js"; +import type * as streamSessions from "../streamSessions.js"; +import type * as threadTitle from "../threadTitle.js"; +import type * as threads from "../threads.js"; +import type * as usage from "../usage.js"; +import type * as users from "../users.js"; -import type { ApiFromModules, FilterApi, FunctionReference } from 'convex/server'; +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; declare const fullApi: ApiFromModules<{ - analytics: typeof analytics; - analyticsEvents: typeof analyticsEvents; - apiHelpers: typeof apiHelpers; - authHelpers: typeof authHelpers; - clerkApiKeys: typeof clerkApiKeys; - clerkApiKeysQueries: typeof clerkApiKeysQueries; - crons: typeof crons; - githubAuth: typeof githubAuth; - githubConnections: typeof githubConnections; - http: typeof http; - 'instances/actions': typeof instances_actions; - 'instances/mutations': typeof instances_mutations; - 'instances/queries': typeof instances_queries; - mcp: typeof mcp; - 'mcp/resourceContract': typeof mcp_resourceContract; - mcpInternal: typeof mcpInternal; - mcpQuestions: typeof mcpQuestions; - messages: typeof messages; - migrations: typeof migrations; - privateWrappers: typeof privateWrappers; - projects: typeof projects; - resourceActions: typeof resourceActions; - resources: typeof resources; - 'scheduled/queries': typeof scheduled_queries; - 'scheduled/updates': typeof scheduled_updates; - 'scheduled/versionCheck': typeof scheduled_versionCheck; - streamSessions: typeof streamSessions; - threadTitle: typeof threadTitle; - threads: typeof threads; - usage: typeof usage; - users: typeof users; + analytics: typeof analytics; + analyticsEvents: typeof analyticsEvents; + apiHelpers: typeof apiHelpers; + authHelpers: typeof authHelpers; + clerkApiKeys: typeof clerkApiKeys; + clerkApiKeysQueries: typeof clerkApiKeysQueries; + crons: typeof crons; + githubApp: typeof githubApp; + githubAuth: typeof githubAuth; + githubConnections: typeof githubConnections; + http: typeof http; + "instances/actions": typeof instances_actions; + "instances/mutations": typeof instances_mutations; + "instances/queries": typeof instances_queries; + mcp: typeof mcp; + "mcp/resourceContract": typeof mcp_resourceContract; + mcpInternal: typeof mcpInternal; + mcpQuestions: typeof mcpQuestions; + messages: typeof messages; + migrations: typeof migrations; + privateWrappers: typeof privateWrappers; + projects: typeof projects; + resourceActions: typeof resourceActions; + resources: typeof resources; + runtimeConfigLock: typeof runtimeConfigLock; + "scheduled/queries": typeof scheduled_queries; + "scheduled/updates": typeof scheduled_updates; + "scheduled/versionCheck": typeof scheduled_versionCheck; + streamSessions: typeof streamSessions; + threadTitle: typeof threadTitle; + threads: typeof threads; + usage: typeof usage; + users: typeof users; }>; /** @@ -84,7 +92,10 @@ declare const fullApi: ApiFromModules<{ * const myFunctionReference = api.myModule.myFunction; * ``` */ -export declare const api: FilterApi>; +export declare const api: FilterApi< + typeof fullApi, + FunctionReference +>; /** * A utility for referencing Convex functions in your app's internal API. @@ -94,88 +105,96 @@ export declare const api: FilterApi>; +export declare const internal: FilterApi< + typeof fullApi, + FunctionReference +>; export declare const components: { - migrations: { - lib: { - cancel: FunctionReference< - 'mutation', - 'internal', - { name: string }, - { - batchSize?: number; - cursor?: string | null; - error?: string; - isDone: boolean; - latestEnd?: number; - latestStart: number; - name: string; - next?: Array; - processed: number; - state: 'inProgress' | 'success' | 'failed' | 'canceled' | 'unknown'; - } - >; - cancelAll: FunctionReference< - 'mutation', - 'internal', - { sinceTs?: number }, - Array<{ - batchSize?: number; - cursor?: string | null; - error?: string; - isDone: boolean; - latestEnd?: number; - latestStart: number; - name: string; - next?: Array; - processed: number; - state: 'inProgress' | 'success' | 'failed' | 'canceled' | 'unknown'; - }> - >; - clearAll: FunctionReference<'mutation', 'internal', { before?: number }, null>; - getStatus: FunctionReference< - 'query', - 'internal', - { limit?: number; names?: Array }, - Array<{ - batchSize?: number; - cursor?: string | null; - error?: string; - isDone: boolean; - latestEnd?: number; - latestStart: number; - name: string; - next?: Array; - processed: number; - state: 'inProgress' | 'success' | 'failed' | 'canceled' | 'unknown'; - }> - >; - migrate: FunctionReference< - 'mutation', - 'internal', - { - batchSize?: number; - cursor?: string | null; - dryRun: boolean; - fnHandle: string; - name: string; - next?: Array<{ fnHandle: string; name: string }>; - oneBatchOnly?: boolean; - }, - { - batchSize?: number; - cursor?: string | null; - error?: string; - isDone: boolean; - latestEnd?: number; - latestStart: number; - name: string; - next?: Array; - processed: number; - state: 'inProgress' | 'success' | 'failed' | 'canceled' | 'unknown'; - } - >; - }; - }; + migrations: { + lib: { + cancel: FunctionReference< + "mutation", + "internal", + { name: string }, + { + batchSize?: number; + cursor?: string | null; + error?: string; + isDone: boolean; + latestEnd?: number; + latestStart: number; + name: string; + next?: Array; + processed: number; + state: "inProgress" | "success" | "failed" | "canceled" | "unknown"; + } + >; + cancelAll: FunctionReference< + "mutation", + "internal", + { sinceTs?: number }, + Array<{ + batchSize?: number; + cursor?: string | null; + error?: string; + isDone: boolean; + latestEnd?: number; + latestStart: number; + name: string; + next?: Array; + processed: number; + state: "inProgress" | "success" | "failed" | "canceled" | "unknown"; + }> + >; + clearAll: FunctionReference< + "mutation", + "internal", + { before?: number }, + null + >; + getStatus: FunctionReference< + "query", + "internal", + { limit?: number; names?: Array }, + Array<{ + batchSize?: number; + cursor?: string | null; + error?: string; + isDone: boolean; + latestEnd?: number; + latestStart: number; + name: string; + next?: Array; + processed: number; + state: "inProgress" | "success" | "failed" | "canceled" | "unknown"; + }> + >; + migrate: FunctionReference< + "mutation", + "internal", + { + batchSize?: number; + cursor?: string | null; + dryRun: boolean; + fnHandle: string; + name: string; + next?: Array<{ fnHandle: string; name: string }>; + oneBatchOnly?: boolean; + }, + { + batchSize?: number; + cursor?: string | null; + error?: string; + isDone: boolean; + latestEnd?: number; + latestStart: number; + name: string; + next?: Array; + processed: number; + state: "inProgress" | "success" | "failed" | "canceled" | "unknown"; + } + >; + }; + }; }; diff --git a/apps/web/src/convex/_generated/api.js b/apps/web/src/convex/_generated/api.js index 24593c74..44bf9858 100644 --- a/apps/web/src/convex/_generated/api.js +++ b/apps/web/src/convex/_generated/api.js @@ -8,7 +8,7 @@ * @module */ -import { anyApi, componentsGeneric } from 'convex/server'; +import { anyApi, componentsGeneric } from "convex/server"; /** * A utility for referencing Convex functions in your app's API. diff --git a/apps/web/src/convex/_generated/dataModel.d.ts b/apps/web/src/convex/_generated/dataModel.d.ts index 5428df6f..f97fd194 100644 --- a/apps/web/src/convex/_generated/dataModel.d.ts +++ b/apps/web/src/convex/_generated/dataModel.d.ts @@ -9,13 +9,13 @@ */ import type { - DataModelFromSchemaDefinition, - DocumentByName, - TableNamesInDataModel, - SystemTableNames -} from 'convex/server'; -import type { GenericId } from 'convex/values'; -import schema from '../schema.js'; + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; /** * The names of all of your Convex tables. @@ -27,7 +27,10 @@ export type TableNames = TableNamesInDataModel; * * @typeParam TableName - A string literal type of the table name (like "users"). */ -export type Doc = DocumentByName; +export type Doc = DocumentByName< + DataModel, + TableName +>; /** * An identifier for a document in Convex. @@ -42,7 +45,8 @@ export type Doc = DocumentByName = GenericId; +export type Id = + GenericId; /** * A type describing your Convex data model. diff --git a/apps/web/src/convex/_generated/server.d.ts b/apps/web/src/convex/_generated/server.d.ts index 1cc047ef..bec05e68 100644 --- a/apps/web/src/convex/_generated/server.d.ts +++ b/apps/web/src/convex/_generated/server.d.ts @@ -9,17 +9,17 @@ */ import { - ActionBuilder, - HttpActionBuilder, - MutationBuilder, - QueryBuilder, - GenericActionCtx, - GenericMutationCtx, - GenericQueryCtx, - GenericDatabaseReader, - GenericDatabaseWriter -} from 'convex/server'; -import type { DataModel } from './dataModel.js'; + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; /** * Define a query in this Convex app's public API. @@ -29,7 +29,7 @@ import type { DataModel } from './dataModel.js'; * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ -export declare const query: QueryBuilder; +export declare const query: QueryBuilder; /** * Define a query that is only accessible from other Convex functions (but not from the client). @@ -39,7 +39,7 @@ export declare const query: QueryBuilder; * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ -export declare const internalQuery: QueryBuilder; +export declare const internalQuery: QueryBuilder; /** * Define a mutation in this Convex app's public API. @@ -49,7 +49,7 @@ export declare const internalQuery: QueryBuilder; * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ -export declare const mutation: MutationBuilder; +export declare const mutation: MutationBuilder; /** * Define a mutation that is only accessible from other Convex functions (but not from the client). @@ -59,7 +59,7 @@ export declare const mutation: MutationBuilder; * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ -export declare const internalMutation: MutationBuilder; +export declare const internalMutation: MutationBuilder; /** * Define an action in this Convex app's public API. @@ -72,7 +72,7 @@ export declare const internalMutation: MutationBuilder; * @param func - The action. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped action. Include this as an `export` to name it and make it accessible. */ -export declare const action: ActionBuilder; +export declare const action: ActionBuilder; /** * Define an action that is only accessible from other Convex functions (but not from the client). @@ -80,7 +80,7 @@ export declare const action: ActionBuilder; * @param func - The function. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped function. Include this as an `export` to name it and make it accessible. */ -export declare const internalAction: ActionBuilder; +export declare const internalAction: ActionBuilder; /** * Define an HTTP action. diff --git a/apps/web/src/convex/_generated/server.js b/apps/web/src/convex/_generated/server.js index a18aa285..bf3d25ad 100644 --- a/apps/web/src/convex/_generated/server.js +++ b/apps/web/src/convex/_generated/server.js @@ -9,14 +9,14 @@ */ import { - actionGeneric, - httpActionGeneric, - queryGeneric, - mutationGeneric, - internalActionGeneric, - internalMutationGeneric, - internalQueryGeneric -} from 'convex/server'; + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, +} from "convex/server"; /** * Define a query in this Convex app's public API. diff --git a/apps/web/src/convex/githubApp.ts b/apps/web/src/convex/githubApp.ts new file mode 100644 index 00000000..d256d5f8 --- /dev/null +++ b/apps/web/src/convex/githubApp.ts @@ -0,0 +1,334 @@ +'use node'; + +import { + WebAuthError, + WebConfigMissingError, + WebUnhandledError, + WebValidationError +} from '../lib/result/errors'; + +const GITHUB_API_BASE_URL = 'https://api.github.com'; +const GITHUB_API_VERSION = '2022-11-28'; +const GITHUB_APP_JWT_LIFETIME_SECONDS = 9 * 60; + +export type GitHubRepoRef = { + owner: string; + repo: string; +}; + +export type GitHubAppInstallationSnapshot = { + installationId: number; + accountLogin: string; + accountType: 'User' | 'Organization'; + targetType: 'User' | 'Organization'; + repositorySelection: 'all' | 'selected'; + repositoryIds: number[]; + repositoryNames: string[]; + contentsPermission?: string; + metadataPermission?: string; + htmlUrl?: string; + status: 'active' | 'suspended'; + connectedAt: number; + lastSyncedAt: number; + suspendedAt?: number; +}; + +type GitHubAppInfo = { + slug: string; + name?: string; + html_url?: string; +}; + +type GitHubInstallationAccount = { + login: string; + type: 'User' | 'Organization'; +}; + +type GitHubInstallationResponse = { + id: number; + account: GitHubInstallationAccount | null; + target_type?: 'User' | 'Organization'; + repository_selection: 'all' | 'selected'; + permissions?: Record; + html_url?: string; + suspended_at?: string | null; +}; + +type GitHubInstallationRepositoriesResponse = { + repositories: Array<{ + id: number; + full_name: string; + private: boolean; + default_branch: string; + }>; +}; + +type GitHubRepoResponse = { + id: number; + full_name: string; + private: boolean; + default_branch: string; +}; + +let cachedPrivateKey: CryptoKey | null = null; +let cachedAppInfo: GitHubAppInfo | null = null; + +const textEncoder = new TextEncoder(); + +const requireEnv = (name: string) => { + const value = process.env[name]; + if (!value) { + throw new WebConfigMissingError({ + message: `${name} is not set in the Convex environment`, + config: name + }); + } + return value; +}; + +const normalizePrivateKey = (value: string) => value.replace(/\\n/g, '\n').trim(); + +const bytesToBase64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)); + +const stringToBase64 = (value: string) => bytesToBase64(textEncoder.encode(value)); + +const base64UrlEncode = (value: string | Uint8Array) => + (typeof value === 'string' ? stringToBase64(value) : bytesToBase64(value)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + +const parsePem = (pem: string) => { + const contents = pem.replace(/-----BEGIN [A-Z ]+-----|-----END [A-Z ]+-----|\s+/g, ''); + const normalized = contents.replace(/-/g, '+').replace(/_/g, '/'); + const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4)); + const binary = atob(`${normalized}${padding}`); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +}; + +const getGitHubHeaders = (token?: string) => ({ + Accept: 'application/vnd.github+json', + 'User-Agent': 'btca-web', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + ...(token ? { Authorization: `Bearer ${token}` } : {}) +}); + +const importPrivateKey = async () => { + if (cachedPrivateKey) { + return cachedPrivateKey; + } + + const pem = normalizePrivateKey(requireEnv('GITHUB_APP_PRIVATE_KEY')); + cachedPrivateKey = await crypto.subtle.importKey( + 'pkcs8', + parsePem(pem), + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + false, + ['sign'] + ); + + return cachedPrivateKey; +}; + +const createGitHubAppJwt = async () => { + const privateKey = await importPrivateKey(); + const header = base64UrlEncode(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); + const nowSeconds = Math.floor(Date.now() / 1000); + const payload = base64UrlEncode( + JSON.stringify({ + iat: nowSeconds - 30, + exp: nowSeconds + GITHUB_APP_JWT_LIFETIME_SECONDS, + iss: requireEnv('GITHUB_APP_ID') + }) + ); + const unsignedToken = `${header}.${payload}`; + const signature = await crypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + privateKey, + textEncoder.encode(unsignedToken) + ); + return `${unsignedToken}.${base64UrlEncode(new Uint8Array(signature))}`; +}; + +const githubRequest = async ( + path: string, + init: RequestInit = {}, + token?: string +): Promise => + fetch(`${GITHUB_API_BASE_URL}${path}`, { + ...init, + headers: { + ...getGitHubHeaders(token), + ...(init.headers ?? {}) + } + }); + +const appRequest = async (path: string, init: RequestInit = {}) => + githubRequest(path, init, await createGitHubAppJwt()); + +const parseJson = async (response: Response): Promise => (await response.json()) as T; + +const assertOk = async (response: Response, context: string) => { + if (response.ok) return; + const details = await response.text().catch(() => ''); + throw new WebUnhandledError({ + message: `${context} failed with status ${response.status}${details ? `: ${details}` : ''}` + }); +}; + +export const parseGitHubRepoRef = (url: string): GitHubRepoRef | null => { + try { + const parsed = new URL(url); + if (parsed.hostname.toLowerCase() !== 'github.com') return null; + const [owner, repo] = parsed.pathname + .split('/') + .filter(Boolean) + .slice(0, 2) + .map((segment) => segment.replace(/\.git$/, '')); + if (!owner || !repo) return null; + return { owner, repo }; + } catch { + return null; + } +}; + +export const getRepoFullName = (repoRef: GitHubRepoRef) => `${repoRef.owner}/${repoRef.repo}`; + +export const fetchGitHubRepo = async (repoRef: GitHubRepoRef, token?: string) => + githubRequest(`/repos/${repoRef.owner}/${repoRef.repo}`, {}, token); + +export const ensureGitHubBranch = async ( + repoRef: GitHubRepoRef, + branch: string, + token?: string +) => { + const response = await githubRequest( + `/repos/${repoRef.owner}/${repoRef.repo}/branches/${encodeURIComponent(branch)}`, + {}, + token + ); + + if (response.ok) return; + if (response.status === 404) { + throw new WebValidationError({ + message: `Branch "${branch}" was not found on ${repoRef.owner}/${repoRef.repo}`, + field: 'branch' + }); + } + + const details = await response.text().catch(() => ''); + throw new WebUnhandledError({ + message: `GitHub branch lookup failed with status ${response.status}${details ? `: ${details}` : ''}` + }); +}; + +export const getAppInfo = async () => { + if (cachedAppInfo) { + return cachedAppInfo; + } + + requireEnv('GITHUB_APP_CLIENT_ID'); + requireEnv('GITHUB_APP_CLIENT_SECRET'); + const response = await appRequest('/app'); + await assertOk(response, 'GitHub app lookup'); + cachedAppInfo = await parseJson(response); + return cachedAppInfo; +}; + +export const getInstallationSnapshot = async ( + installationId: number +): Promise => { + const response = await appRequest(`/app/installations/${installationId}`); + if (response.status === 404) { + return null; + } + await assertOk(response, 'GitHub installation lookup'); + const installation = await parseJson(response); + + if (!installation.account?.login || !installation.account.type) { + throw new WebUnhandledError({ + message: `GitHub installation ${installationId} is missing account metadata` + }); + } + + const connectedAt = Date.now(); + const lastSyncedAt = connectedAt; + const repositorySelection = installation.repository_selection; + const repositoryNames: string[] = []; + const repositoryIds: number[] = []; + + if (repositorySelection === 'selected') { + const token = await createInstallationToken(installationId); + let nextUrl = `${GITHUB_API_BASE_URL}/installation/repositories?per_page=100`; + while (nextUrl) { + const pageResponse = await fetch(nextUrl, { + headers: getGitHubHeaders(token) + }); + await assertOk(pageResponse, 'GitHub installation repository listing'); + const page = await parseJson(pageResponse); + for (const repository of page.repositories) { + repositoryIds.push(repository.id); + repositoryNames.push(repository.full_name); + } + + const linkHeader = pageResponse.headers.get('link') ?? ''; + const nextMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/); + nextUrl = nextMatch?.[1] ?? ''; + } + } + + return { + installationId, + accountLogin: installation.account.login, + accountType: installation.account.type, + targetType: installation.target_type ?? installation.account.type, + repositorySelection, + repositoryIds, + repositoryNames, + contentsPermission: installation.permissions?.contents, + metadataPermission: installation.permissions?.metadata, + htmlUrl: installation.html_url, + status: installation.suspended_at ? 'suspended' : 'active', + connectedAt, + lastSyncedAt, + suspendedAt: installation.suspended_at + ? new Date(installation.suspended_at).getTime() + : undefined + }; +}; + +export const createInstallationToken = async (installationId: number) => { + const response = await appRequest(`/app/installations/${installationId}/access_tokens`, { + method: 'POST' + }); + + if (response.status === 404) { + throw new WebAuthError({ + message: 'The GitHub App installation no longer exists. Reconnect GitHub and try again.', + code: 'UNAUTHORIZED' + }); + } + + await assertOk(response, 'GitHub installation token creation'); + const body = await parseJson<{ token?: string }>(response); + if (!body.token) { + throw new WebUnhandledError({ + message: `GitHub installation ${installationId} did not return an access token` + }); + } + + return body.token; +}; + +export const resolveAccessibleRepo = async (installationId: number, repoRef: GitHubRepoRef) => { + const token = await createInstallationToken(installationId); + const response = await fetchGitHubRepo(repoRef, token); + if (response.status === 404) { + return null; + } + await assertOk(response, 'GitHub repository lookup'); + return { + token, + repo: await parseJson(response) + }; +}; diff --git a/apps/web/src/convex/githubAuth.ts b/apps/web/src/convex/githubAuth.ts index a77a1e80..6b801d6f 100644 --- a/apps/web/src/convex/githubAuth.ts +++ b/apps/web/src/convex/githubAuth.ts @@ -1,32 +1,52 @@ 'use node'; -import { createClerkClient } from '@clerk/backend'; import type { FunctionReference } from 'convex/server'; import { v } from 'convex/values'; -import { internal } from './_generated/api'; +import { api, internal } from './_generated/api'; import type { Id } from './_generated/dataModel'; import { action } from './_generated/server'; import { instances } from './apiHelpers'; -import { WebAuthError, WebConfigMissingError, WebUnhandledError } from '../lib/result/errors'; - -const GITHUB_PROVIDER = 'github'; -const REQUIRED_GITHUB_SCOPES = ['repo'] as const; +import { getInstallationSnapshot } from './githubApp'; +import { WebAuthError, WebUnhandledError } from '../lib/result/errors'; type InternalGithubConnections = { + getByInstanceId: FunctionReference< + 'query', + 'internal', + { instanceId: Id<'instances'> }, + Array<{ + installationId: number; + }> + >; upsertForInstance: FunctionReference< 'mutation', 'internal', { instanceId: Id<'instances'>; clerkUserId: string; - githubUserId?: number; - githubLogin?: string; - scopes: string[]; - status: 'connected' | 'missing_scope' | 'disconnected'; - connectedAt?: number; + installationId: number; + accountLogin: string; + accountType: 'User' | 'Organization'; + targetType: 'User' | 'Organization'; + repositorySelection: 'all' | 'selected'; + repositoryIds: number[]; + repositoryNames: string[]; + contentsPermission?: string; + metadataPermission?: string; + htmlUrl?: string; + status: 'active' | 'suspended' | 'deleted'; + connectedAt: number; + lastSyncedAt: number; + suspendedAt?: number; }, - Id<'githubConnections'> + Id<'githubInstallations'> + >; + markDeletedByInstallationId: FunctionReference< + 'mutation', + 'internal', + { installationId: number }, + null >; }; @@ -34,132 +54,54 @@ const githubConnectionsInternal = internal as unknown as { githubConnections: InternalGithubConnections; }; -type GitHubUser = { - id: number; - login: string; -}; - -export type GitHubConnectionSnapshot = - | { - status: 'connected' | 'missing_scope'; - scopes: string[]; - githubUserId: number; - githubLogin: string; - connectedAt?: number; - token: string; - } - | { - status: 'disconnected'; - scopes: string[]; - githubUserId?: undefined; - githubLogin?: undefined; - connectedAt?: undefined; - token?: undefined; - }; - -const getClerkClient = () => { - const secretKey = process.env.CLERK_SECRET_KEY; - if (!secretKey) { - throw new WebConfigMissingError({ - message: 'CLERK_SECRET_KEY environment variable is not set', - config: 'CLERK_SECRET_KEY' - }); - } - return createClerkClient({ secretKey }); -}; - -const normalizeScopes = (scopes: string[]) => - [...new Set(scopes.map((scope) => scope.trim()).filter(Boolean))].sort(); - -const parseScopesHeader = (value: string | null) => - normalizeScopes( - (value ?? '') - .split(',') - .map((scope) => scope.trim()) - .filter(Boolean) - ); - -const hasRequiredGitHubScopes = (scopes: string[]) => - REQUIRED_GITHUB_SCOPES.every((scope) => scopes.includes(scope)); - -const getGitHubHeaders = (token: string) => ({ - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'User-Agent': 'btca-web' -}); - -const fetchGitHubUser = async (token: string): Promise<{ user: GitHubUser; scopes: string[] }> => { - const response = await fetch('https://api.github.com/user', { - headers: getGitHubHeaders(token) - }); - - if (response.status === 401 || response.status === 403) { - throw new WebAuthError({ - message: 'GitHub access token is no longer valid', - code: 'UNAUTHORIZED' - }); - } - - if (!response.ok) { - throw new WebUnhandledError({ - message: `GitHub user lookup failed with status ${response.status}` - }); - } - - const user = (await response.json()) as GitHubUser; - const scopes = parseScopesHeader(response.headers.get('x-oauth-scopes')); - return { user, scopes }; -}; - -export const inspectGitHubConnectionForClerkUser = async ( - clerkUserId: string -): Promise => { - const clerkClient = getClerkClient(); - const oauthTokens = await clerkClient.users.getUserOauthAccessToken(clerkUserId, GITHUB_PROVIDER); - const tokenData = oauthTokens.data[0]; - - if (!tokenData?.token) { - return { - status: 'disconnected', - scopes: [] - }; - } - - let userResult: { user: GitHubUser; scopes: string[] }; - try { - userResult = await fetchGitHubUser(tokenData.token); - } catch (error) { - if (WebAuthError.is(error)) { - return { - status: 'disconnected', - scopes: [] - }; - } - throw error; - } - const { user, scopes: headerScopes } = userResult; - const scopes = normalizeScopes([...(tokenData.scopes ?? []), ...headerScopes]); - - return { - status: hasRequiredGitHubScopes(scopes) ? 'connected' : 'missing_scope', - scopes, - githubUserId: user.id, - githubLogin: user.login, - connectedAt: Date.now(), - token: tokenData.token - }; +type GitHubConnectionSummary = { + status: 'connected' | 'disconnected'; + installations: Array<{ + installationId: number; + accountLogin: string; + accountType: 'User' | 'Organization'; + targetType: 'User' | 'Organization'; + repositorySelection: 'all' | 'selected'; + repositoryIds: number[]; + repositoryNames: string[]; + contentsPermission?: string; + metadataPermission?: string; + htmlUrl?: string; + status: 'active' | 'suspended' | 'deleted'; + connectedAt: number; + lastSyncedAt: number; + suspendedAt?: number; + }>; + connectedAt?: number; + lastSyncedAt?: number; }; export const syncMyConnection = action({ args: {}, returns: v.object({ - status: v.union(v.literal('connected'), v.literal('missing_scope'), v.literal('disconnected')), - scopes: v.array(v.string()), - githubUserId: v.optional(v.number()), - githubLogin: v.optional(v.string()), - connectedAt: v.optional(v.number()) + status: v.union(v.literal('connected'), v.literal('disconnected')), + installations: v.array( + v.object({ + installationId: v.number(), + accountLogin: v.string(), + accountType: v.union(v.literal('User'), v.literal('Organization')), + targetType: v.union(v.literal('User'), v.literal('Organization')), + repositorySelection: v.union(v.literal('all'), v.literal('selected')), + repositoryIds: v.array(v.number()), + repositoryNames: v.array(v.string()), + contentsPermission: v.optional(v.string()), + metadataPermission: v.optional(v.string()), + htmlUrl: v.optional(v.string()), + status: v.union(v.literal('active'), v.literal('suspended'), v.literal('deleted')), + connectedAt: v.number(), + lastSyncedAt: v.number(), + suspendedAt: v.optional(v.number()) + }) + ), + connectedAt: v.optional(v.number()), + lastSyncedAt: v.optional(v.number()) }), - handler: async (ctx) => { + handler: async (ctx): Promise => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new WebAuthError({ @@ -175,23 +117,45 @@ export const syncMyConnection = action({ throw new WebUnhandledError({ message: 'Instance not found for authenticated user' }); } - const snapshot = await inspectGitHubConnectionForClerkUser(identity.subject); - await ctx.runMutation(githubConnectionsInternal.githubConnections.upsertForInstance, { - instanceId: instance._id, - clerkUserId: identity.subject, - githubUserId: snapshot.githubUserId, - githubLogin: snapshot.githubLogin, - scopes: snapshot.scopes, - status: snapshot.status, - connectedAt: snapshot.connectedAt - }); + const installations = await ctx.runQuery( + githubConnectionsInternal.githubConnections.getByInstanceId, + { + instanceId: instance._id + } + ); + + for (const installation of installations) { + const snapshot = await getInstallationSnapshot(installation.installationId); + if (!snapshot) { + await ctx.runMutation( + githubConnectionsInternal.githubConnections.markDeletedByInstallationId, + { + installationId: installation.installationId + } + ); + continue; + } + + await ctx.runMutation(githubConnectionsInternal.githubConnections.upsertForInstance, { + instanceId: instance._id, + clerkUserId: identity.subject, + installationId: snapshot.installationId, + accountLogin: snapshot.accountLogin, + accountType: snapshot.accountType, + targetType: snapshot.targetType, + repositorySelection: snapshot.repositorySelection, + repositoryIds: snapshot.repositoryIds, + repositoryNames: snapshot.repositoryNames, + contentsPermission: snapshot.contentsPermission, + metadataPermission: snapshot.metadataPermission, + htmlUrl: snapshot.htmlUrl, + status: snapshot.status, + connectedAt: snapshot.connectedAt, + lastSyncedAt: snapshot.lastSyncedAt, + suspendedAt: snapshot.suspendedAt + }); + } - return { - status: snapshot.status, - scopes: snapshot.scopes, - githubUserId: snapshot.githubUserId, - githubLogin: snapshot.githubLogin, - connectedAt: snapshot.connectedAt - }; + return await ctx.runQuery(api.githubConnections.getMyConnection, {}); } }); diff --git a/apps/web/src/convex/githubConnections.ts b/apps/web/src/convex/githubConnections.ts index 6f4ce40b..bd35dc13 100644 --- a/apps/web/src/convex/githubConnections.ts +++ b/apps/web/src/convex/githubConnections.ts @@ -3,39 +3,154 @@ import { v } from 'convex/values'; import { internalMutation, internalQuery, query } from './_generated/server'; import { getAuthenticatedInstanceResult, unwrapAuthResult } from './authHelpers'; -const githubConnectionValidator = v.object({ - _id: v.id('githubConnections'), +const githubInstallationValidator = v.object({ + _id: v.id('githubInstallations'), _creationTime: v.number(), instanceId: v.id('instances'), clerkUserId: v.string(), - githubUserId: v.optional(v.number()), - githubLogin: v.optional(v.string()), - scopes: v.array(v.string()), - status: v.union(v.literal('connected'), v.literal('missing_scope'), v.literal('disconnected')), + installationId: v.number(), + accountLogin: v.string(), + accountType: v.union(v.literal('User'), v.literal('Organization')), + targetType: v.union(v.literal('User'), v.literal('Organization')), + repositorySelection: v.union(v.literal('all'), v.literal('selected')), + repositoryIds: v.array(v.number()), + repositoryNames: v.array(v.string()), + contentsPermission: v.optional(v.string()), + metadataPermission: v.optional(v.string()), + htmlUrl: v.optional(v.string()), + status: v.union(v.literal('active'), v.literal('suspended'), v.literal('deleted')), + connectedAt: v.number(), + lastSyncedAt: v.number(), + suspendedAt: v.optional(v.number()) +}); + +const githubConnectionSummaryValidator = v.object({ + status: v.union(v.literal('connected'), v.literal('disconnected')), + installations: v.array( + v.object({ + installationId: v.number(), + accountLogin: v.string(), + accountType: v.union(v.literal('User'), v.literal('Organization')), + targetType: v.union(v.literal('User'), v.literal('Organization')), + repositorySelection: v.union(v.literal('all'), v.literal('selected')), + repositoryIds: v.array(v.number()), + repositoryNames: v.array(v.string()), + contentsPermission: v.optional(v.string()), + metadataPermission: v.optional(v.string()), + htmlUrl: v.optional(v.string()), + status: v.union(v.literal('active'), v.literal('suspended'), v.literal('deleted')), + connectedAt: v.number(), + lastSyncedAt: v.number(), + suspendedAt: v.optional(v.number()) + }) + ), connectedAt: v.optional(v.number()), - lastValidatedAt: v.number() + lastSyncedAt: v.optional(v.number()) }); +const toSummary = ( + installations: Array<{ + _id?: unknown; + _creationTime?: number; + instanceId?: unknown; + clerkUserId?: string; + installationId: number; + accountLogin: string; + accountType: 'User' | 'Organization'; + targetType: 'User' | 'Organization'; + repositorySelection: 'all' | 'selected'; + repositoryIds: number[]; + repositoryNames: string[]; + contentsPermission?: string; + metadataPermission?: string; + htmlUrl?: string; + status: 'active' | 'suspended' | 'deleted'; + connectedAt: number; + lastSyncedAt: number; + suspendedAt?: number; + }> +) => { + const activeInstallations = installations + .filter((installation) => installation.status !== 'deleted') + .map((installation) => ({ + installationId: installation.installationId, + accountLogin: installation.accountLogin, + accountType: installation.accountType, + targetType: installation.targetType, + repositorySelection: installation.repositorySelection, + repositoryIds: installation.repositoryIds, + repositoryNames: installation.repositoryNames, + contentsPermission: installation.contentsPermission, + metadataPermission: installation.metadataPermission, + htmlUrl: installation.htmlUrl, + status: installation.status, + connectedAt: installation.connectedAt, + lastSyncedAt: installation.lastSyncedAt, + suspendedAt: installation.suspendedAt + })) + .sort((a, b) => a.accountLogin.localeCompare(b.accountLogin)); + + return { + status: activeInstallations.length > 0 ? ('connected' as const) : ('disconnected' as const), + installations: activeInstallations, + connectedAt: + activeInstallations.length > 0 + ? Math.min(...activeInstallations.map((installation) => installation.connectedAt)) + : undefined, + lastSyncedAt: + activeInstallations.length > 0 + ? Math.max(...activeInstallations.map((installation) => installation.lastSyncedAt)) + : undefined + }; +}; + export const getMyConnection = query({ args: {}, - returns: v.union(v.null(), githubConnectionValidator), + returns: githubConnectionSummaryValidator, handler: async (ctx) => { const instance = await unwrapAuthResult(await getAuthenticatedInstanceResult(ctx)); - return await ctx.db - .query('githubConnections') + const installations = await ctx.db + .query('githubInstallations') .withIndex('by_instance', (q) => q.eq('instanceId', instance._id)) - .first(); + .collect(); + return toSummary(installations); } }); export const getByInstanceId = internalQuery({ args: { instanceId: v.id('instances') }, - returns: v.union(v.null(), githubConnectionValidator), + returns: v.array(githubInstallationValidator), handler: async (ctx, args) => { return await ctx.db - .query('githubConnections') + .query('githubInstallations') .withIndex('by_instance', (q) => q.eq('instanceId', args.instanceId)) - .first(); + .collect(); + } +}); + +export const getByOwner = internalQuery({ + args: { instanceId: v.id('instances'), accountLogin: v.string() }, + returns: v.array(githubInstallationValidator), + handler: async (ctx, args) => { + const accountLogin = args.accountLogin.toLowerCase(); + const installations = await ctx.db + .query('githubInstallations') + .withIndex('by_instance_and_account_login', (q) => + q.eq('instanceId', args.instanceId).eq('accountLogin', accountLogin) + ) + .collect(); + return installations.sort((a, b) => b.lastSyncedAt - a.lastSyncedAt); + } +}); + +export const getByInstallationId = internalQuery({ + args: { installationId: v.number() }, + returns: v.array(githubInstallationValidator), + handler: async (ctx, args) => { + return await ctx.db + .query('githubInstallations') + .withIndex('by_installation_id', (q) => q.eq('installationId', args.installationId)) + .collect(); } }); @@ -43,27 +158,47 @@ export const upsertForInstance = internalMutation({ args: { instanceId: v.id('instances'), clerkUserId: v.string(), - githubUserId: v.optional(v.number()), - githubLogin: v.optional(v.string()), - scopes: v.array(v.string()), - status: v.union(v.literal('connected'), v.literal('missing_scope'), v.literal('disconnected')), - connectedAt: v.optional(v.number()) + installationId: v.number(), + accountLogin: v.string(), + accountType: v.union(v.literal('User'), v.literal('Organization')), + targetType: v.union(v.literal('User'), v.literal('Organization')), + repositorySelection: v.union(v.literal('all'), v.literal('selected')), + repositoryIds: v.array(v.number()), + repositoryNames: v.array(v.string()), + contentsPermission: v.optional(v.string()), + metadataPermission: v.optional(v.string()), + htmlUrl: v.optional(v.string()), + status: v.union(v.literal('active'), v.literal('suspended'), v.literal('deleted')), + connectedAt: v.number(), + lastSyncedAt: v.number(), + suspendedAt: v.optional(v.number()) }, - returns: v.id('githubConnections'), + returns: v.id('githubInstallations'), handler: async (ctx, args) => { + const normalizedAccountLogin = args.accountLogin.toLowerCase(); const existing = await ctx.db - .query('githubConnections') - .withIndex('by_instance', (q) => q.eq('instanceId', args.instanceId)) + .query('githubInstallations') + .withIndex('by_instance_and_installation', (q) => + q.eq('instanceId', args.instanceId).eq('installationId', args.installationId) + ) .first(); const patch = { clerkUserId: args.clerkUserId, - githubUserId: args.githubUserId, - githubLogin: args.githubLogin, - scopes: args.scopes, + installationId: args.installationId, + accountLogin: normalizedAccountLogin, + accountType: args.accountType, + targetType: args.targetType, + repositorySelection: args.repositorySelection, + repositoryIds: args.repositoryIds, + repositoryNames: args.repositoryNames, + contentsPermission: args.contentsPermission, + metadataPermission: args.metadataPermission, + htmlUrl: args.htmlUrl, status: args.status, connectedAt: args.connectedAt, - lastValidatedAt: Date.now() + lastSyncedAt: args.lastSyncedAt, + suspendedAt: args.suspendedAt }; if (existing) { @@ -71,9 +206,33 @@ export const upsertForInstance = internalMutation({ return existing._id; } - return await ctx.db.insert('githubConnections', { + return await ctx.db.insert('githubInstallations', { instanceId: args.instanceId, ...patch }); } }); + +export const markDeletedByInstallationId = internalMutation({ + args: { + installationId: v.number() + }, + returns: v.null(), + handler: async (ctx, args) => { + const records = await ctx.db + .query('githubInstallations') + .withIndex('by_installation_id', (q) => q.eq('installationId', args.installationId)) + .collect(); + + await Promise.all( + records.map((record) => + ctx.db.patch(record._id, { + status: 'deleted', + lastSyncedAt: Date.now() + }) + ) + ); + + return null; + } +}); diff --git a/apps/web/src/convex/http.ts b/apps/web/src/convex/http.ts index 6d487884..ee62ff08 100644 --- a/apps/web/src/convex/http.ts +++ b/apps/web/src/convex/http.ts @@ -1,4 +1,5 @@ import { formatConversationHistory, type BtcaChunk, type ThreadMessage } from '@btca/shared'; +import type { FunctionReference } from 'convex/server'; import { httpRouter } from 'convex/server'; import { nanoid } from 'nanoid'; import { z } from 'zod'; @@ -9,8 +10,16 @@ import type { Id } from './_generated/dataModel.js'; import { httpAction, type ActionCtx } from './_generated/server.js'; import { AnalyticsEvents } from './analyticsEvents.js'; import { instances } from './apiHelpers.js'; +import { getAppInfo, getInstallationSnapshot } from './githubApp.js'; import { withPrivateApiKey } from './privateWrappers.js'; -import { WebUnhandledError, type WebError } from '../lib/result/errors'; +import { + INSTANCE_DISK_FULL_MESSAGE, + getInstanceErrorKind, + getUserFacingInstanceError +} from '../lib/instanceErrors'; +import { getWebSandboxModel } from '../lib/models/webSandboxModels.ts'; +import { WebConfigMissingError, WebUnhandledError, type WebError } from '../lib/result/errors'; +import { withInstanceRuntimeConfigLock } from './runtimeConfigLock.js'; type HttpFlowResult = Result; @@ -18,12 +27,56 @@ const usageActions = api.usage; const instanceActions = instances.actions; const instanceMutations = instances.mutations; const instanceQueries = instances.queries; +const githubConnectionsInternal = internal as unknown as { + githubConnections: { + upsertForInstance: FunctionReference< + 'mutation', + 'internal', + { + instanceId: Id<'instances'>; + clerkUserId: string; + installationId: number; + accountLogin: string; + accountType: 'User' | 'Organization'; + targetType: 'User' | 'Organization'; + repositorySelection: 'all' | 'selected'; + repositoryIds: number[]; + repositoryNames: string[]; + contentsPermission?: string; + metadataPermission?: string; + htmlUrl?: string; + status: 'active' | 'suspended' | 'deleted'; + connectedAt: number; + lastSyncedAt: number; + suspendedAt?: number; + }, + Id<'githubInstallations'> + >; + getByInstallationId: FunctionReference< + 'query', + 'internal', + { installationId: number }, + Array<{ + instanceId: Id<'instances'>; + clerkUserId: string; + installationId: number; + }> + >; + markDeletedByInstallationId: FunctionReference< + 'mutation', + 'internal', + { installationId: number }, + null + >; + }; +}; const http = httpRouter(); const corsAllowedMethods = 'GET, POST, OPTIONS'; const corsMaxAgeSeconds = 60 * 60 * 24; const defaultAllowedHeaders = 'Content-Type, Authorization, X-Requested-With'; +const localDevHosts = new Set(['localhost', '127.0.0.1']); const buildAllowedOrigins = (): Set => { const origins = (process.env.CLIENT_ORIGIN ?? '') @@ -32,13 +85,14 @@ const buildAllowedOrigins = (): Set => { .filter(Boolean); if (origins.length === 0) { - return new Set(['http://localhost:5173', 'http://localhost:5174', 'http://localhost:5175']); + return new Set(); } return new Set(origins); }; const allowedOrigins = buildAllowedOrigins(); +const githubConnectStateLifetimeMs = 10 * 60 * 1000; type SvixHeaders = { 'svix-id': string; @@ -77,7 +131,8 @@ type MessageLike = { type ChunkUpdate = | { type: 'add'; chunk: BtcaChunk } - | { type: 'update'; id: string; chunk: Partial }; + | { type: 'update'; id: string; chunk: Partial } + | { type: 'append'; id: string; chunkType: 'text' | 'reasoning'; delta: string }; type BtcaToolState = { status?: 'pending' | 'running' | 'completed' | 'error'; @@ -100,6 +155,9 @@ type BtcaStreamDoneEvent = { outputTokens?: number; reasoningTokens?: number; totalTokens?: number; + cachedTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; }; metrics?: { timing?: { totalMs?: number; genMs?: number }; @@ -141,6 +199,7 @@ type BtcaStreamEvent = type InstanceRecord = { _id: Id<'instances'>; state: string; + errorKind?: 'disk_full' | 'generic'; serverUrl?: string | null; sandboxId?: string | null; }; @@ -157,6 +216,55 @@ type StreamEventPayload = | { type: 'done' } | ChunkUpdate; +const toMessageStats = (doneEvent: BtcaStreamDoneEvent | null) => { + if (!doneEvent) return undefined; + + const inputTokens = doneEvent.usage?.inputTokens; + const outputTokens = + doneEvent.usage?.outputTokens != null || doneEvent.usage?.reasoningTokens != null + ? (doneEvent.usage?.outputTokens ?? 0) + (doneEvent.usage?.reasoningTokens ?? 0) + : undefined; + const cachedTokens = + doneEvent.usage?.cachedTokens ?? + (doneEvent.usage?.cacheReadTokens != null || doneEvent.usage?.cacheWriteTokens != null + ? (doneEvent.usage?.cacheReadTokens ?? 0) + (doneEvent.usage?.cacheWriteTokens ?? 0) + : undefined); + const durationMs = doneEvent.metrics?.timing?.totalMs ?? doneEvent.metrics?.timing?.genMs; + const totalTokensFromParts = [inputTokens, outputTokens, cachedTokens].reduce( + (sum, value) => sum + (value ?? 0), + 0 + ); + const totalTokens = + doneEvent.usage?.totalTokens ?? (totalTokensFromParts > 0 ? totalTokensFromParts : undefined); + const tokensPerSecond = + doneEvent.metrics?.throughput?.totalTokensPerSecond ?? + doneEvent.metrics?.throughput?.outputTokensPerSecond ?? + (durationMs && totalTokens ? totalTokens / (durationMs / 1000) : undefined); + const totalPriceUsd = doneEvent.metrics?.pricing?.costUsd?.total; + + if ( + durationMs == null && + inputTokens == null && + outputTokens == null && + cachedTokens == null && + totalTokens == null && + tokensPerSecond == null && + totalPriceUsd == null + ) { + return undefined; + } + + return { + durationMs, + inputTokens, + outputTokens, + cachedTokens, + totalTokens, + tokensPerSecond, + totalPriceUsd + }; +}; + function jsonResponse(payload: unknown, init: ResponseInit = {}): Response { const headers = new Headers(init.headers); headers.set('Content-Type', 'application/json'); @@ -167,6 +275,16 @@ function isOriginAllowed(origin: string | null): boolean { if (!origin) { return false; } + + if (allowedOrigins.size === 0) { + try { + const parsed = new URL(origin); + return localDevHosts.has(parsed.hostname); + } catch { + return false; + } + } + if (allowedOrigins.size === 0) { return false; } @@ -208,6 +326,101 @@ function corsTextResponse(request: Request, message: string, status: number): Re return withCors(request, new Response(message, { status })); } +const getClientOrigin = (request: Request) => { + const origin = request.headers.get('Origin'); + if (origin && isOriginAllowed(origin)) { + return origin; + } + + return [...allowedOrigins][0] ?? 'http://localhost:5173'; +}; + +const getGitHubStateSecret = () => { + const secret = process.env.GITHUB_APP_CLIENT_SECRET; + if (!secret) { + throw new WebConfigMissingError({ + message: 'GITHUB_APP_CLIENT_SECRET is not set in the Convex environment', + config: 'GITHUB_APP_CLIENT_SECRET' + }); + } + return secret; +}; + +const sanitizeReturnTo = (value: string | null) => + value && value.startsWith('/') && !value.startsWith('//') ? value : '/app/settings/resources'; + +const toHex = (bytes: Uint8Array) => + [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join(''); + +const toBase64Url = (value: string) => + btoa(String.fromCharCode(...new TextEncoder().encode(value))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + +const fromBase64Url = (value: string) => { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4)); + const binary = atob(`${normalized}${padding}`); + return new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))); +}; + +const signHmacSha256 = async (secret: string, payload: string) => { + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)); + return toHex(new Uint8Array(signature)); +}; + +const encodeGitHubConnectState = async (payload: { + clerkId: string; + returnTo: string; + issuedAt: number; +}) => { + const body = toBase64Url(JSON.stringify(payload)); + const signature = await signHmacSha256(getGitHubStateSecret(), body); + return `${body}.${signature}`; +}; + +const decodeGitHubConnectState = async (state: string) => { + const [body, signature] = state.split('.', 2); + if (!body || !signature) { + return null; + } + + const expectedSignature = await signHmacSha256(getGitHubStateSecret(), body); + if (signature !== expectedSignature) { + return null; + } + + try { + const parsed = JSON.parse(fromBase64Url(body)) as { + clerkId?: string; + returnTo?: string; + issuedAt?: number; + }; + if (!parsed.clerkId || typeof parsed.issuedAt !== 'number') { + return null; + } + if (Date.now() - parsed.issuedAt > githubConnectStateLifetimeMs) { + return null; + } + + return { + clerkId: parsed.clerkId, + returnTo: sanitizeReturnTo(parsed.returnTo ?? null), + issuedAt: parsed.issuedAt + }; + } catch { + return null; + } +}; + const corsPreflight = httpAction(async (_, request) => { const origin = request.headers.get('Origin'); const headers = new Headers(); @@ -266,6 +479,14 @@ const chatStream = httpAction(async (ctx, request) => { const threadResources = threadWithMessages.threadResources ?? []; const updatedResources = [...new Set([...threadResources, ...selectedResources])]; + const projectId = threadWithMessages.projectId ?? undefined; + const project = projectId + ? await ctx.runQuery(internal.projects.getInternal, { projectId }) + : null; + const modelId = + project && project.instanceId === instance._id + ? getWebSandboxModel(project.model).id + : getWebSandboxModel().id; const threadMessages: ThreadMessage[] = (threadWithMessages.messages ?? []).map( (messageItem: MessageLike) => ({ role: messageItem.role, @@ -282,7 +503,8 @@ const chatStream = httpAction(async (ctx, request) => { const usageCheck = await ctx.runAction(usageActions.ensureUsageAvailable, { instanceId: instance._id, question: questionWithHistory, - resources: updatedResources + resources: updatedResources, + projectId }); if (!usageCheck?.ok) { @@ -320,7 +542,8 @@ const chatStream = httpAction(async (ctx, request) => { const usageData = usageCheck as { inputTokens?: number; - sandboxUsageHours?: number; + requiredBudgetMicros?: number; + modelId?: string; }; const streamStartedAt = Date.now(); @@ -334,7 +557,8 @@ const chatStream = httpAction(async (ctx, request) => { resourceCount: updatedResources.length, resources: updatedResources, inputTokens: usageData.inputTokens ?? 0, - sandboxUsageHours: usageData.sandboxUsageHours ?? 0 + modelId: usageData.modelId ?? modelId, + requiredBudgetMicros: usageData.requiredBudgetMicros ?? 0 } }); @@ -368,221 +592,247 @@ const chatStream = httpAction(async (ctx, request) => { sendEvent({ type: 'session', sessionId } as StreamEventPayload); - const serverAccessResult = await ensureServerUrlResult(ctx, instance, sendEvent); - if (Result.isError(serverAccessResult)) { - throw serverAccessResult.error; - } - const serverAccess = serverAccessResult.value; - - const response = await fetch(`${serverAccess.serverUrl}/question/stream`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(serverAccess.previewToken - ? { 'x-daytona-preview-token': serverAccess.previewToken } - : {}) - }, - body: JSON.stringify({ - question: questionWithHistory, - resources: updatedResources, - quiet: true - }) - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new WebUnhandledError({ - message: errorText || `Server error: ${response.status}`, - cause: new Error(errorText || `Server error: ${response.status}`) + await withInstanceRuntimeConfigLock(instance._id.toString(), async () => { + const serverAccessResult = await ensureServerUrlResult( + ctx, + instance, + projectId, + sendEvent + ); + if (Result.isError(serverAccessResult)) { + throw serverAccessResult.error; + } + const serverAccess = serverAccessResult.value; + + const response = await fetch(`${serverAccess.serverUrl}/question/stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(serverAccess.previewToken + ? { 'x-daytona-preview-token': serverAccess.previewToken } + : {}) + }, + body: JSON.stringify({ + question: questionWithHistory, + resources: updatedResources, + quiet: true + }) }); - } - if (!response.body) { - throw new WebUnhandledError({ message: 'No response body' }); - } - let chunksById = new Map(); - let chunkOrder: string[] = []; - let outputCharCount = 0; - let reasoningCharCount = 0; - let doneEvent: BtcaStreamDoneEvent | null = null; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - let eventData = ''; - for (const line of lines) { - if (line.startsWith('data: ')) { - eventData = line.slice(6); - } else if (line === '' && eventData) { - let event: BtcaStreamEvent; - try { - event = JSON.parse(eventData) as BtcaStreamEvent; - } catch (error) { - console.error('Failed to parse event:', error); - eventData = ''; - continue; - } + if (!response.ok) { + const errorText = await response.text(); + throw new WebUnhandledError({ + message: errorText || `Server error: ${response.status}`, + cause: new Error(errorText || `Server error: ${response.status}`) + }); + } + if (!response.body) { + throw new WebUnhandledError({ message: 'No response body' }); + } - if (event.type === 'error') { - throw new WebUnhandledError({ - message: event.message ?? 'Stream error', - cause: new Error(event.message ?? 'Stream error') - }); - } - if (event.type === 'done') { - doneEvent = event; - } else if (event.type === 'meta') { - // ignore meta events from btca server - } else { - if (event.type === 'text.delta') { - outputCharCount += event.delta.length; - } else if (event.type === 'reasoning.delta') { - reasoningCharCount += event.delta.length; + let chunksById = new Map(); + let chunkOrder: string[] = []; + let outputCharCount = 0; + let reasoningCharCount = 0; + let doneEvent: BtcaStreamDoneEvent | null = null; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + let eventData = ''; + for (const line of lines) { + if (line.startsWith('data: ')) { + eventData = line.slice(6); + } else if (line === '' && eventData) { + let event: BtcaStreamEvent; + try { + event = JSON.parse(eventData) as BtcaStreamEvent; + } catch (error) { + console.error('Failed to parse event:', error); + eventData = ''; + continue; } - const update = processStreamEvent(event, chunksById, chunkOrder); - if (update) { - sendEvent(update); + + if (event.type === 'error') { + throw new WebUnhandledError({ + message: event.message ?? 'Stream error', + cause: new Error(event.message ?? 'Stream error') + }); } + if (event.type === 'done') { + doneEvent = event; + } else if (event.type === 'meta') { + // ignore meta events from btca server + } else { + if (event.type === 'text.delta') { + outputCharCount += event.delta.length; + } else if (event.type === 'reasoning.delta') { + reasoningCharCount += event.delta.length; + } + const update = processStreamEvent(event, chunksById, chunkOrder); + if (update) { + sendEvent(update); + } + } + eventData = ''; } - eventData = ''; } } - } - reader.releaseLock(); - - if (doneEvent) { - const chunkOrderFromDone: string[] = []; - const chunksByIdFromDone = new Map(); - let textCharCount = 0; - let reasoningCharCountFromDone = 0; + reader.releaseLock(); + + if (doneEvent) { + const chunkOrderFromDone: string[] = []; + const chunksByIdFromDone = new Map(); + let textCharCount = 0; + let reasoningCharCountFromDone = 0; + + if (doneEvent.reasoning) { + const reasoningChunkId = '__reasoning__'; + chunksByIdFromDone.set(reasoningChunkId, { + type: 'reasoning', + id: reasoningChunkId, + text: doneEvent.reasoning + }); + chunkOrderFromDone.push(reasoningChunkId); + reasoningCharCountFromDone = doneEvent.reasoning.length; + } - if (doneEvent.reasoning) { - const reasoningChunkId = '__reasoning__'; - chunksByIdFromDone.set(reasoningChunkId, { - type: 'reasoning', - id: reasoningChunkId, - text: doneEvent.reasoning - }); - chunkOrderFromDone.push(reasoningChunkId); - reasoningCharCountFromDone = doneEvent.reasoning.length; - } + if (doneEvent.tools.length > 0) { + for (const tool of doneEvent.tools) { + const toolState = + tool.state?.status === 'pending' + ? 'pending' + : tool.state?.status === 'running' + ? 'running' + : 'completed'; + const toolChunk: BtcaChunk = { + type: 'tool', + id: tool.callID, + toolName: tool.tool, + state: toolState + }; + chunksByIdFromDone.set(tool.callID, toolChunk); + chunkOrderFromDone.push(tool.callID); + } + } - if (doneEvent.tools.length > 0) { - for (const tool of doneEvent.tools) { - const toolState = - tool.state?.status === 'pending' - ? 'pending' - : tool.state?.status === 'running' - ? 'running' - : 'completed'; - const toolChunk: BtcaChunk = { - type: 'tool', - id: tool.callID, - toolName: tool.tool, - state: toolState - }; - chunksByIdFromDone.set(tool.callID, toolChunk); - chunkOrderFromDone.push(tool.callID); + if (doneEvent.text) { + const textChunkId = '__text__'; + chunksByIdFromDone.set(textChunkId, { + type: 'text', + id: textChunkId, + text: doneEvent.text + }); + chunkOrderFromDone.push(textChunkId); + textCharCount = doneEvent.text.length; } - } - if (doneEvent.text) { - const textChunkId = '__text__'; - chunksByIdFromDone.set(textChunkId, { - type: 'text', - id: textChunkId, - text: doneEvent.text - }); - chunkOrderFromDone.push(textChunkId); - textCharCount = doneEvent.text.length; + chunksById = chunksByIdFromDone; + chunkOrder = chunkOrderFromDone; + outputCharCount = textCharCount; + reasoningCharCount = reasoningCharCountFromDone; } - chunksById = chunksByIdFromDone; - chunkOrder = chunkOrderFromDone; - outputCharCount = textCharCount; - reasoningCharCount = reasoningCharCountFromDone; - } + const assistantContent = { + type: 'chunks' as const, + chunks: chunkOrder + .map((id) => chunksById.get(id)) + .filter((chunk): chunk is BtcaChunk => chunk !== undefined) + }; - const assistantContent = { - type: 'chunks' as const, - chunks: chunkOrder - .map((id) => chunksById.get(id)) - .filter((chunk): chunk is BtcaChunk => chunk !== undefined) - }; - - if (!assistantMessageId) { - throw new WebUnhandledError({ message: 'Missing assistant message' }); - } - await ctx.runMutation(api.messages.updateAssistantMessage, { - messageId: assistantMessageId, - content: assistantContent - }); - await ctx.runMutation( - instanceMutations.touchActivity, - withPrivateApiKey({ instanceId: instance._id }) - ); - - const outputTokensData = { - questionTokens: usageData.inputTokens ?? 0, - outputChars: outputCharCount, - reasoningChars: reasoningCharCount, - resources: updatedResources, - sandboxUsageHours: usageData.sandboxUsageHours ?? 0 - }; - - try { - await ctx.runAction(usageActions.finalizeUsage, { - instanceId: instance._id, - ...outputTokensData + if (!assistantMessageId) { + throw new WebUnhandledError({ message: 'Missing assistant message' }); + } + await ctx.runMutation(api.messages.updateAssistantMessage, { + messageId: assistantMessageId, + content: assistantContent, + stats: toMessageStats(doneEvent) }); - } catch (error) { - console.error('Failed to track usage:', error); - } - - await ctx.runMutation( - instanceMutations.scheduleSyncSandboxStatus, - withPrivateApiKey({ instanceId: instance._id }) - ); + await ctx.runMutation( + instanceMutations.touchActivity, + withPrivateApiKey({ instanceId: instance._id }) + ); + + const actualUsage = doneEvent?.usage; + + let chargedBudgetMicros = 0; + try { + const finalizeResult = await ctx.runAction(usageActions.finalizeUsage, { + instanceId: instance._id, + modelId: usageData.modelId ?? modelId, + inputTokens: actualUsage?.inputTokens ?? usageData.inputTokens ?? 0, + outputTokens: actualUsage?.outputTokens ?? 0, + reasoningTokens: actualUsage?.reasoningTokens ?? 0, + cacheReadTokens: actualUsage?.cacheReadTokens ?? 0, + cacheWriteTokens: actualUsage?.cacheWriteTokens ?? 0, + chargedBudgetMicros: + doneEvent?.metrics?.pricing?.costUsd?.total != null + ? Math.max(0, Math.round(doneEvent.metrics.pricing.costUsd.total * 1_000_000)) + : undefined + }); + chargedBudgetMicros = finalizeResult.chargedBudgetMicros ?? 0; + } catch (error) { + console.error('Failed to track usage:', error); + } - await ctx.runMutation(api.streamSessions.complete, withPrivateApiKey({ sessionId })); + await ctx.runMutation( + instanceMutations.scheduleSyncSandboxStatus, + withPrivateApiKey({ instanceId: instance._id }) + ); - const streamDurationMs = Date.now() - streamStartedAt; - const toolsUsed = chunkOrder - .map((id) => chunksById.get(id)) - .filter((c): c is BtcaChunk => c?.type === 'tool') - .map((c) => (c as { toolName: string }).toolName); + await ctx.runMutation(api.streamSessions.complete, withPrivateApiKey({ sessionId })); - await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, { - distinctId: identity.subject, - event: AnalyticsEvents.STREAM_COMPLETED, - properties: { - instanceId: instance._id, - threadId: resolvedThreadId, - durationMs: streamDurationMs, - outputChars: outputCharCount, - reasoningChars: reasoningCharCount, - toolsUsed, - toolCount: toolsUsed.length, - resourcesUsed: updatedResources, - resourceCount: updatedResources.length, - inputTokens: usageData.inputTokens ?? 0, - sandboxUsageHours: usageData.sandboxUsageHours ?? 0 - } + const streamDurationMs = Date.now() - streamStartedAt; + const toolsUsed = chunkOrder + .map((id) => chunksById.get(id)) + .filter((c): c is BtcaChunk => c?.type === 'tool') + .map((c) => (c as { toolName: string }).toolName); + + await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, { + distinctId: identity.subject, + event: AnalyticsEvents.STREAM_COMPLETED, + properties: { + instanceId: instance._id, + threadId: resolvedThreadId, + durationMs: streamDurationMs, + outputChars: outputCharCount, + reasoningChars: reasoningCharCount, + toolsUsed, + toolCount: toolsUsed.length, + resourcesUsed: updatedResources, + resourceCount: updatedResources.length, + modelId: usageData.modelId ?? modelId, + inputTokens: actualUsage?.inputTokens ?? usageData.inputTokens ?? 0, + outputTokens: actualUsage?.outputTokens ?? 0, + reasoningTokens: actualUsage?.reasoningTokens ?? 0, + chargedBudgetMicros + } + }); }); sendEvent({ type: 'done' }); controller.close(); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (getInstanceErrorKind(error) === 'disk_full') { + await ctx.runMutation( + instanceMutations.setError, + withPrivateApiKey({ + instanceId: instance._id, + errorKind: 'disk_full', + errorMessage: getUserFacingInstanceError(error, errorMessage) + }) + ); + } const streamDurationMs = Date.now() - streamStartedAt; await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, { @@ -686,6 +936,184 @@ const clerkWebhook = httpAction(async (ctx, request) => { return withCors(request, response); }); +const githubConnectStart = httpAction(async (ctx, request) => { + try { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + return corsTextResponse(request, 'Unauthorized', 401); + } + + const url = new URL(request.url); + const returnTo = sanitizeReturnTo(url.searchParams.get('returnTo')); + const state = await encodeGitHubConnectState({ + clerkId: identity.subject, + returnTo, + issuedAt: Date.now() + }); + const appInfo = await getAppInfo(); + const response = jsonResponse({ + url: `https://github.com/apps/${appInfo.slug}/installations/new?state=${encodeURIComponent(state)}` + }); + return withCors(request, response); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to start GitHub connect flow'; + return withCors(request, jsonResponse({ error: message }, { status: 500 })); + } +}); + +const githubConnectCallback = httpAction(async (ctx, request) => { + const url = new URL(request.url); + const state = await decodeGitHubConnectState(url.searchParams.get('state') ?? ''); + const clientOrigin = getClientOrigin(request); + const redirectUrl = new URL(state?.returnTo ?? '/app/settings/resources', clientOrigin); + + if (!state) { + redirectUrl.searchParams.set('github', 'error'); + redirectUrl.searchParams.set('github_error', 'invalid_state'); + return Response.redirect(redirectUrl.toString(), 302); + } + + const installationIdValue = Number(url.searchParams.get('installation_id')); + if (!Number.isFinite(installationIdValue) || installationIdValue <= 0) { + redirectUrl.searchParams.set('github', 'error'); + redirectUrl.searchParams.set('github_error', 'missing_installation'); + return Response.redirect(redirectUrl.toString(), 302); + } + + const instance = await ctx.runQuery(instances.internalQueries.getByClerkIdInternal, { + clerkId: state.clerkId + }); + if (!instance) { + redirectUrl.searchParams.set('github', 'error'); + redirectUrl.searchParams.set('github_error', 'missing_instance'); + return Response.redirect(redirectUrl.toString(), 302); + } + + const snapshot = await getInstallationSnapshot(installationIdValue); + if (!snapshot) { + redirectUrl.searchParams.set('github', 'error'); + redirectUrl.searchParams.set('github_error', 'missing_installation'); + return Response.redirect(redirectUrl.toString(), 302); + } + + await ctx.runMutation(githubConnectionsInternal.githubConnections.upsertForInstance, { + instanceId: instance._id, + clerkUserId: state.clerkId, + installationId: snapshot.installationId, + accountLogin: snapshot.accountLogin, + accountType: snapshot.accountType, + targetType: snapshot.targetType, + repositorySelection: snapshot.repositorySelection, + repositoryIds: snapshot.repositoryIds, + repositoryNames: snapshot.repositoryNames, + contentsPermission: snapshot.contentsPermission, + metadataPermission: snapshot.metadataPermission, + htmlUrl: snapshot.htmlUrl, + status: snapshot.status, + connectedAt: snapshot.connectedAt, + lastSyncedAt: snapshot.lastSyncedAt, + suspendedAt: snapshot.suspendedAt + }); + + redirectUrl.searchParams.set('github', 'connected'); + const setupAction = url.searchParams.get('setup_action'); + if (setupAction) { + redirectUrl.searchParams.set('setup_action', setupAction); + } + return Response.redirect(redirectUrl.toString(), 302); +}); + +const verifyGitHubWebhookSignature = async ( + payload: string, + signature: string | null, + secret: string +) => { + if (!signature?.startsWith('sha256=')) { + return false; + } + + const expected = await signHmacSha256(secret, payload); + return signature === `sha256=${expected}`; +}; + +const githubWebhook = httpAction(async (ctx, request) => { + const secret = process.env.GITHUB_APP_WEBHOOK_SECRET; + if (!secret) { + return jsonResponse({ error: 'Missing GitHub webhook secret' }, { status: 500 }); + } + + const payload = await request.text(); + const signature = request.headers.get('x-hub-signature-256'); + const isValid = await verifyGitHubWebhookSignature(payload, signature, secret); + if (!isValid) { + return jsonResponse({ error: 'Invalid webhook signature' }, { status: 400 }); + } + + const event = request.headers.get('x-github-event') ?? ''; + let body: { + action?: string; + installation?: { id?: number }; + }; + try { + body = JSON.parse(payload) as { + action?: string; + installation?: { id?: number }; + }; + } catch { + return jsonResponse({ error: 'Invalid webhook payload' }, { status: 400 }); + } + const installationId = body.installation?.id; + + if (!installationId || !['installation', 'installation_repositories'].includes(event)) { + return jsonResponse({ received: true }); + } + + if (event === 'installation' && body.action === 'deleted') { + await ctx.runMutation(githubConnectionsInternal.githubConnections.markDeletedByInstallationId, { + installationId + }); + return jsonResponse({ received: true }); + } + + const snapshot = await getInstallationSnapshot(installationId); + if (!snapshot) { + await ctx.runMutation(githubConnectionsInternal.githubConnections.markDeletedByInstallationId, { + installationId + }); + return jsonResponse({ received: true }); + } + + const linkedInstallations = await ctx.runQuery( + githubConnectionsInternal.githubConnections.getByInstallationId, + { installationId } + ); + + await Promise.all( + linkedInstallations.map((record) => + ctx.runMutation(githubConnectionsInternal.githubConnections.upsertForInstance, { + instanceId: record.instanceId, + clerkUserId: record.clerkUserId, + installationId: snapshot.installationId, + accountLogin: snapshot.accountLogin, + accountType: snapshot.accountType, + targetType: snapshot.targetType, + repositorySelection: snapshot.repositorySelection, + repositoryIds: snapshot.repositoryIds, + repositoryNames: snapshot.repositoryNames, + contentsPermission: snapshot.contentsPermission, + metadataPermission: snapshot.metadataPermission, + htmlUrl: snapshot.htmlUrl, + status: snapshot.status, + connectedAt: snapshot.connectedAt, + lastSyncedAt: snapshot.lastSyncedAt, + suspendedAt: snapshot.suspendedAt + }) + ) + ); + + return jsonResponse({ received: true }); +}); + http.route({ path: '/chat/stream', method: 'POST', @@ -710,6 +1138,30 @@ http.route({ handler: corsPreflight }); +http.route({ + path: '/github/connect/start', + method: 'GET', + handler: githubConnectStart +}); + +http.route({ + path: '/github/connect/start', + method: 'OPTIONS', + handler: corsPreflight +}); + +http.route({ + path: '/github/connect/callback', + method: 'GET', + handler: githubConnectCallback +}); + +http.route({ + path: '/webhooks/github', + method: 'POST', + handler: githubWebhook +}); + const daytonaWebhook = httpAction(async (ctx, request) => { const secret = process.env.DAYTONA_WEBHOOK_SECRET; if (!secret) { @@ -850,7 +1302,7 @@ function processStreamEvent( const existing = chunksById.get(textChunkId); if (existing && existing.type === 'text') { existing.text += event.delta; - return { type: 'update', id: textChunkId, chunk: { text: existing.text } }; + return { type: 'append', id: textChunkId, chunkType: 'text', delta: event.delta }; } const chunk: BtcaChunk = { type: 'text', id: textChunkId, text: event.delta }; @@ -864,7 +1316,12 @@ function processStreamEvent( const existing = chunksById.get(reasoningChunkId); if (existing && existing.type === 'reasoning') { existing.text += event.delta; - return { type: 'update', id: reasoningChunkId, chunk: { text: existing.text } }; + return { + type: 'append', + id: reasoningChunkId, + chunkType: 'reasoning', + delta: event.delta + }; } const chunk: BtcaChunk = { @@ -907,10 +1364,18 @@ function processStreamEvent( async function ensureServerUrlResult( ctx: ActionCtx, instance: InstanceRecord, + projectId: Id<'projects'> | undefined, sendEvent: (payload: StreamEventPayload) => void ): Promise> { if (instance.state === 'error') { - return Result.err(new WebUnhandledError({ message: 'Instance is in an error state' })); + return Result.err( + new WebUnhandledError({ + message: + instance.errorKind === 'disk_full' + ? INSTANCE_DISK_FULL_MESSAGE + : 'Instance is in an error state' + }) + ); } if (instance.state === 'provisioning' || instance.state === 'unprovisioned') { @@ -923,11 +1388,27 @@ async function ensureServerUrlResult( return Result.ok({ serverUrl: instance.serverUrl }); } - const previewAccess = await ctx.runAction(internal.instances.actions.getPreviewAccess, { - instanceId: instance._id - }); - sendEvent({ type: 'status', status: 'ready' }); - return Result.ok(previewAccess); + let shouldWake = false; + if (projectId) { + const syncResult = await ctx.runAction(internal.instances.actions.syncResources, { + instanceId: instance._id, + projectId, + includePrivate: true + }); + if (!syncResult.synced) { + // Convex state can briefly say "running" after Daytona has already stopped the sandbox. + // Reuse the wake flow in that case so project chats recover instead of surfacing a false error. + shouldWake = true; + } + } + + if (!shouldWake) { + const previewAccess = await ctx.runAction(internal.instances.actions.getPreviewAccess, { + instanceId: instance._id + }); + sendEvent({ type: 'status', status: 'ready' }); + return Result.ok(previewAccess); + } } if (!instance.sandboxId) { @@ -944,7 +1425,7 @@ async function ensureServerUrlResult( try { const result = await ctx.runAction( instanceActions.wake, - withPrivateApiKey({ instanceId: instance._id }) + withPrivateApiKey({ instanceId: instance._id, projectId }) ); const serverUrl = result.serverUrl; if (!serverUrl) { diff --git a/apps/web/src/convex/instances/actions.ts b/apps/web/src/convex/instances/actions.ts index e26616cc..6eec3bef 100644 --- a/apps/web/src/convex/instances/actions.ts +++ b/apps/web/src/convex/instances/actions.ts @@ -3,6 +3,7 @@ import { createClerkClient } from '@clerk/backend'; import { Daytona, type Sandbox } from '@daytonaio/sdk'; import { BTCA_SNAPSHOT_NAME } from 'btca-sandbox/shared'; +import type { FunctionReference } from 'convex/server'; import { v } from 'convex/values'; import { Result } from 'better-result'; @@ -11,8 +12,15 @@ import type { Doc, Id } from '../_generated/dataModel'; import { action, internalAction, type ActionCtx } from '../_generated/server'; import { AnalyticsEvents } from '../analyticsEvents'; import { instances } from '../apiHelpers'; -import { inspectGitHubConnectionForClerkUser } from '../githubAuth'; +import { + createInstallationToken, + fetchGitHubRepo, + getRepoFullName, + parseGitHubRepoRef +} from '../githubApp'; import { privateAction, withPrivateApiKey } from '../privateWrappers'; +import { getInstanceErrorKind, getUserFacingInstanceError } from '../../lib/instanceErrors'; +import { getWebSandboxModel } from '../../lib/models/webSandboxModels.ts'; import { WebAuthError, WebConfigMissingError, @@ -20,13 +28,12 @@ import { WebValidationError, type WebError } from '../../lib/result/errors'; +import { withInstanceRuntimeConfigLock } from '../runtimeConfigLock.js'; const instanceQueries = instances.queries; const instanceMutations = instances.mutations; const BTCA_SERVER_PORT = 3000; const SANDBOX_IDLE_MINUTES = 2; -const DEFAULT_MODEL = 'claude-haiku-4-5'; -const DEFAULT_PROVIDER = 'opencode'; const BTCA_SERVER_SESSION = 'btca-server-session'; const BTCA_SERVER_LOG_PATH = '/tmp/btca-server.log'; const BTCA_PACKAGE_NAME = 'btca@latest'; @@ -44,7 +51,7 @@ type ResourceConfig = { searchPath?: string; gitProvider?: 'github' | 'generic'; visibility?: 'public' | 'private'; - authSource?: 'clerk_github_oauth'; + authSource?: 'clerk_github_oauth' | 'github_app'; } | { type: 'npm'; @@ -65,6 +72,27 @@ type PreviewAccess = { let daytonaInstance: Daytona | null = null; type InstanceActionResult = Result; +type GitHubInstallationRecord = { + installationId: number; + repositorySelection: 'all' | 'selected'; + repositoryNames: string[]; + status: 'active' | 'suspended' | 'deleted'; +}; + +const githubConnectionsInternal = internal as unknown as { + githubConnections: { + getByOwner: FunctionReference< + 'query', + 'internal', + { + instanceId: Id<'instances'>; + accountLogin: string; + }, + GitHubInstallationRecord[] + >; + }; +}; + const getClerkClient = () => { const secretKey = process.env.CLERK_SECRET_KEY; if (!secretKey) { @@ -121,7 +149,7 @@ function getDaytonaResult(): InstanceActionResult { return Result.ok(daytonaInstance); } -function generateBtcaConfig(resources: ResourceConfig[]): string { +function generateBtcaConfig(resources: ResourceConfig[], model = getWebSandboxModel()): string { return JSON.stringify( { $schema: 'https://btca.dev/btca.schema.json', @@ -143,8 +171,8 @@ function generateBtcaConfig(resources: ResourceConfig[]): string { specialNotes: resource.specialNotes } ), - model: DEFAULT_MODEL, - provider: DEFAULT_PROVIDER + model: model.id, + provider: model.provider }, null, 2 @@ -211,9 +239,16 @@ const getErrorDetails = (error: unknown) => { return { message: 'Unknown error' }; }; -const getErrorContext = (error: unknown) => { +const getErrorContext = (error: unknown): Record | undefined => { if (!error || typeof error !== 'object') return undefined; - return 'context' in error ? (error as { context?: Record }).context : undefined; + const directContext = + 'context' in error ? (error as { context?: Record }).context : undefined; + if (directContext) { + return directContext; + } + + const cause = 'cause' in error ? (error as { cause?: unknown }).cause : undefined; + return cause ? getErrorContext(cause) : undefined; }; const attachErrorContext = (error: unknown, context: Record) => { @@ -233,10 +268,10 @@ const throwInstanceError = (error: WebError): never => { }; const unwrapInstance = (result: InstanceActionResult): T => { - return Result.match(result, { - ok: (value) => value, - err: (error) => throwInstanceError(error) - }); + if (Result.isError(result)) { + throwInstanceError(result.error); + } + return (result as { value: T }).value; }; const withStep = async ( @@ -261,7 +296,9 @@ const formatUserMessage = (operation: string, step: string | undefined, detail?: ? 'Starting' : operation === 'update' ? 'Updating' - : 'Instance'; + : operation === 'migrate' + ? 'Migrating' + : 'Instance'; const stepLabel = step ? { load_resources: 'loading resources', @@ -273,7 +310,8 @@ const formatUserMessage = (operation: string, step: string | undefined, detail?: health_check: 'waiting for btca to respond', get_versions: 'checking package versions', update_packages: 'updating packages', - stop_sandbox: 'stopping the sandbox' + stop_sandbox: 'stopping the sandbox', + delete_old_sandbox: 'cleaning up the previous sandbox' }[step] : undefined; const base = `${actionLabel} failed${stepLabel ? ` while ${stepLabel}` : ''}.`; @@ -281,6 +319,30 @@ const formatUserMessage = (operation: string, step: string | undefined, detail?: return `${base}${trimmed ? ` ${trimmed}` : ''} Please retry.`; }; +const requiresSnapshotMigration = (instance: Doc<'instances'>) => + instance.snapshotName !== BTCA_SNAPSHOT_NAME; + +async function setInstanceError( + ctx: ActionCtx, + instanceId: Id<'instances'>, + error: unknown, + fallbackMessage: string +) { + const errorKind = getInstanceErrorKind(error); + const errorMessage = getUserFacingInstanceError(error, fallbackMessage); + + await ctx.runMutation( + instanceMutations.setError, + withPrivateApiKey({ + instanceId, + errorKind, + errorMessage + }) + ); + + return { errorKind, errorMessage }; +} + async function getResourceConfigs( ctx: ActionCtx, instanceId: Id<'instances'>, @@ -323,6 +385,23 @@ async function getResourceConfigs( return [...merged.values()]; } +async function getSandboxModelConfig( + ctx: ActionCtx, + instanceId: Id<'instances'>, + projectId?: Id<'projects'> +) { + if (!projectId) { + return getWebSandboxModel(); + } + + const project = await ctx.runQuery(internal.projects.getInternal, { projectId }); + if (!project || project.instanceId !== instanceId) { + throw new WebValidationError({ message: 'Project not found', field: 'projectId' }); + } + + return getWebSandboxModel(project.model); +} + async function requireInstance( ctx: ActionCtx, instanceId: Id<'instances'> @@ -342,8 +421,12 @@ async function requireInstanceResult( return Result.ok(instance); } -async function uploadBtcaConfig(sandbox: Sandbox, resources: ResourceConfig[]): Promise { - const config = generateBtcaConfig(resources); +async function uploadBtcaConfig( + sandbox: Sandbox, + resources: ResourceConfig[], + model = getWebSandboxModel() +): Promise { + const config = generateBtcaConfig(resources, model); await sandbox.fs.uploadFile(Buffer.from(config), '/root/btca.config.jsonc'); } @@ -353,38 +436,160 @@ const requiresGitHubAuth = (resources: ResourceConfig[]) => resource.type === 'git' && resource.gitProvider === 'github' && resource.visibility === 'private' && - resource.authSource === 'clerk_github_oauth' + (resource.authSource === 'clerk_github_oauth' || resource.authSource === 'github_app') ); -async function syncGitHubAuth(sandbox: Sandbox, clerkUserId: string, resources: ResourceConfig[]) { - if (!requiresGitHubAuth(resources)) { - await sandbox.process.executeCommand('rm -f /root/.netrc'); - return; - } - - const connection = await inspectGitHubConnectionForClerkUser(clerkUserId); - if (connection.status === 'disconnected') { +const getClerkGitHubToken = async (clerkUserId: string) => { + const oauthTokens = await getClerkClient().users.getUserOauthAccessToken(clerkUserId, 'github'); + const tokenData = oauthTokens.data[0]; + if (!tokenData?.token) { throw new WebAuthError({ - message: 'Connect GitHub in your profile before using private GitHub repositories.', + message: 'Reconnect any older private GitHub resources before using them in the web sandbox.', code: 'UNAUTHORIZED' }); } - if (connection.status === 'missing_scope') { - throw new WebAuthError({ - message: 'Reconnect GitHub with private repository access before using private repos.', - code: 'FORBIDDEN' - }); + return tokenData.token; +}; + +const escapeShellSingleQuoted = (value: string) => value.replace(/'/g, `'\\''`); + +const buildGitHubCredentialHelperScript = ( + credentials: Array<{ owner: string; repo: string; token: string }> +) => { + const cases = credentials + .map( + ({ owner, repo, token }) => ` '${escapeShellSingleQuoted(`github.com:${owner}/${repo}`)}') + printf '%s\n' 'username=x-access-token' + printf '%s\n' 'password=${escapeShellSingleQuoted(token)}' + exit 0 + ;;` + ) + .join('\n'); + + return `#!/bin/sh +host="" +path="" +while IFS='=' read -r key value; do + case "$key" in + host) host="$value" ;; + path) path="$value" ;; + esac +done + +path="\${path#/}" +path="\${path%.git}" + +case "$host:$path" in +${cases} +esac + +exit 0 +`; +}; + +async function syncGitHubAuth( + ctx: ActionCtx, + sandbox: Sandbox, + instanceId: Id<'instances'>, + clerkUserId: string, + resources: ResourceConfig[] +) { + if (!requiresGitHubAuth(resources)) { + await sandbox.process.executeCommand( + 'rm -f /root/.netrc /root/.btca-github-credential-helper.sh && git config --global --unset-all credential.helper >/dev/null 2>&1 || true && git config --global --unset credential.useHttpPath >/dev/null 2>&1 || true' + ); + return; } - const netrc = [ - `machine github.com`, - `login x-access-token`, - `password ${connection.token}`, - '' - ].join('\n'); - await sandbox.fs.uploadFile(Buffer.from(netrc), '/root/.netrc'); - await sandbox.process.executeCommand('chmod 600 /root/.netrc'); + const credentials: Array<{ owner: string; repo: string; token: string }> = []; + const installationTokenCache = new Map(); + let clerkToken: string | null = null; + + for (const resource of resources) { + if ( + resource.type !== 'git' || + resource.gitProvider !== 'github' || + resource.visibility !== 'private' + ) { + continue; + } + + const repoRef = parseGitHubRepoRef(resource.url); + if (!repoRef) { + continue; + } + + if (resource.authSource === 'github_app') { + const repoFullName = getRepoFullName(repoRef); + const activeInstallations = ( + (await ctx.runQuery(githubConnectionsInternal.githubConnections.getByOwner, { + instanceId, + accountLogin: repoRef.owner.toLowerCase() + })) as GitHubInstallationRecord[] + ).filter((installation) => installation.status === 'active'); + + if (activeInstallations.length === 0) { + throw new WebAuthError({ + message: `Connect GitHub and install the btca GitHub App on ${repoRef.owner} before using private repositories.`, + code: 'UNAUTHORIZED' + }); + } + + let token: string | null = null; + for (const installation of activeInstallations) { + if ( + installation.repositorySelection === 'selected' && + installation.repositoryNames.length > 0 && + !installation.repositoryNames.includes(repoFullName) + ) { + continue; + } + + token = + installationTokenCache.get(installation.installationId) ?? + (await createInstallationToken(installation.installationId)); + installationTokenCache.set(installation.installationId, token); + + const repoResponse = await fetchGitHubRepo(repoRef, token); + if (repoResponse.status === 404) { + continue; + } + if (!repoResponse.ok) { + throw new WebUnhandledError({ + message: `GitHub repository lookup failed with status ${repoResponse.status}` + }); + } + + break; + } + + if (!token) { + throw new WebAuthError({ + message: `Grant the ${repoFullName} repository to the btca GitHub App before using private repositories.`, + code: 'FORBIDDEN' + }); + } + + credentials.push({ ...repoRef, token }); + continue; + } + + if (!clerkToken) { + clerkToken = await getClerkGitHubToken(clerkUserId); + } + + credentials.push({ ...repoRef, token: clerkToken }); + } + + const helperPath = '/root/.btca-github-credential-helper.sh'; + const helperScript = buildGitHubCredentialHelperScript(credentials); + await sandbox.fs.uploadFile(Buffer.from(helperScript), helperPath); + await sandbox.process.executeCommand(`chmod 700 ${helperPath}`); + await sandbox.process.executeCommand('rm -f /root/.netrc'); + await sandbox.process.executeCommand( + `git config --global --unset-all credential.helper >/dev/null 2>&1 || true && git config --global credential.useHttpPath true && git config --global credential.helper '${helperPath}'` + ); } async function getBtcaLogTail(sandbox: Sandbox, lines = 80) { @@ -499,6 +704,66 @@ async function updatePackages(sandbox: Sandbox): Promise { await sandbox.process.executeCommand(`bun add -g ${BTCA_PACKAGE_NAME}`); } +async function createPreparedSandbox( + ctx: ActionCtx, + instanceId: Id<'instances'>, + instance: Doc<'instances'>, + includePrivate = true +): Promise<{ sandbox: Sandbox; versions: InstalledVersions }> { + requireEnv('OPENCODE_API_KEY'); + + let sandbox: Sandbox | null = null; + let step = 'load_resources'; + + try { + const resources = unwrapInstance( + await withStep(step, () => getResourceConfigs(ctx, instanceId, undefined, includePrivate)) + ); + const daytona = getDaytona(); + step = 'create_sandbox'; + const createdSandbox = unwrapInstance( + await withStep(step, () => + daytona.create({ + snapshot: BTCA_SNAPSHOT_NAME, + autoStopInterval: SANDBOX_IDLE_MINUTES, + envVars: { + NODE_ENV: 'production', + OPENCODE_API_KEY: requireEnv('OPENCODE_API_KEY') + }, + public: false + }) + ) + ); + sandbox = createdSandbox; + + step = 'upload_config'; + unwrapInstance( + await withStep(step, () => + syncGitHubAuth(ctx, createdSandbox, instanceId, instance.clerkId, resources) + ) + ); + step = 'upload_config'; + unwrapInstance(await withStep(step, () => uploadBtcaConfig(createdSandbox, resources))); + step = 'get_versions'; + const versions = unwrapInstance( + await withStep(step, () => getInstalledVersions(createdSandbox)) + ); + step = 'stop_sandbox'; + unwrapInstance(await withStep(step, () => stopSandboxIfRunning(createdSandbox))); + + return { sandbox: createdSandbox, versions }; + } catch (error) { + if (sandbox) { + try { + await sandbox.delete(60); + } catch { + // Ignore cleanup errors. + } + } + throw error; + } +} + async function fetchLatestVersion(packageName: string): Promise { try { const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`); @@ -534,50 +799,18 @@ export const provision = privateAction({ ); let sandbox: Sandbox | null = null; - let step = 'load_resources'; + let step = 'create_sandbox'; try { - const resources = unwrapInstance( - await withStep(step, () => getResourceConfigs(ctx, args.instanceId)) - ); - const daytona = getDaytona(); - step = 'create_sandbox'; - const createdSandbox = unwrapInstance( - await withStep(step, () => - daytona.create({ - snapshot: BTCA_SNAPSHOT_NAME, - autoStopInterval: SANDBOX_IDLE_MINUTES, - envVars: { - NODE_ENV: 'production', - OPENCODE_API_KEY: requireEnv('OPENCODE_API_KEY') - }, - public: false - }) - ) - ); - sandbox = createdSandbox; - - step = 'upload_config'; - unwrapInstance( - await withStep(step, () => syncGitHubAuth(createdSandbox, instance.clerkId, resources)) - ); - step = 'upload_config'; - unwrapInstance(await withStep(step, () => uploadBtcaConfig(createdSandbox, resources))); - - step = 'start_btca'; - unwrapInstance(await withStep(step, () => startBtcaServer(createdSandbox))); - - step = 'get_versions'; - const versions = unwrapInstance( - await withStep(step, () => getInstalledVersions(createdSandbox)) - ); - step = 'stop_sandbox'; - unwrapInstance(await withStep(step, () => stopSandboxIfRunning(createdSandbox))); + const preparedSandbox = await createPreparedSandbox(ctx, args.instanceId, instance); + sandbox = preparedSandbox.sandbox; + const versions = preparedSandbox.versions; await ctx.runMutation( instanceMutations.setProvisioned, withPrivateApiKey({ instanceId: args.instanceId, - sandboxId: createdSandbox.id, + sandboxId: sandbox.id, + snapshotName: BTCA_SNAPSHOT_NAME, btcaVersion: versions.btcaVersion }) ); @@ -611,14 +844,6 @@ export const provision = privateAction({ return { sandboxId: sandbox.id }; } catch (error) { - if (sandbox) { - try { - await sandbox.delete(); - } catch { - // Ignore cleanup errors - } - } - const errorDetails = getErrorDetails(error); const context = getErrorContext(error); const contextStep = typeof context?.step === 'string' ? context.step : step; @@ -650,14 +875,8 @@ export const provision = privateAction({ } }); - await ctx.runMutation( - instanceMutations.setError, - withPrivateApiKey({ - instanceId: args.instanceId, - errorMessage: message - }) - ); - throw new WebUnhandledError({ message }); + const { errorMessage } = await setInstanceError(ctx, args.instanceId, error, message); + throw new WebUnhandledError({ message: errorMessage }); } } }); @@ -793,14 +1012,16 @@ async function createSandboxFromScratch( ctx: ActionCtx, instanceId: Id<'instances'>, instance: Doc<'instances'>, + projectId?: Id<'projects'>, includePrivate = true ): Promise<{ sandbox: Sandbox; serverUrl: string }> { requireEnv('OPENCODE_API_KEY'); let step = 'load_resources'; const resources = unwrapInstance( - await withStep(step, () => getResourceConfigs(ctx, instanceId, undefined, includePrivate)) + await withStep(step, () => getResourceConfigs(ctx, instanceId, projectId, includePrivate)) ); + const model = await getSandboxModelConfig(ctx, instanceId, projectId); const daytona = getDaytona(); step = 'create_sandbox'; const sandbox = unwrapInstance( @@ -818,9 +1039,13 @@ async function createSandboxFromScratch( ); step = 'upload_config'; - unwrapInstance(await withStep(step, () => syncGitHubAuth(sandbox, instance.clerkId, resources))); + unwrapInstance( + await withStep(step, () => + syncGitHubAuth(ctx, sandbox, instanceId, instance.clerkId, resources) + ) + ); step = 'upload_config'; - unwrapInstance(await withStep(step, () => uploadBtcaConfig(sandbox, resources))); + unwrapInstance(await withStep(step, () => uploadBtcaConfig(sandbox, resources, model))); step = 'start_btca'; const serverUrl = unwrapInstance(await withStep(step, () => startBtcaServer(sandbox))).serverUrl; step = 'get_versions'; @@ -831,6 +1056,7 @@ async function createSandboxFromScratch( withPrivateApiKey({ instanceId, sandboxId: sandbox.id, + snapshotName: BTCA_SNAPSHOT_NAME, btcaVersion: versions.btcaVersion }) ); @@ -882,7 +1108,13 @@ async function wakeInstanceInternal( if (!instance.sandboxId) { step = 'create_sandbox'; - const result = await createSandboxFromScratch(ctx, instanceId, instance, includePrivate); + const result = await createSandboxFromScratch( + ctx, + instanceId, + instance, + projectId, + includePrivate + ); serverUrl = result.serverUrl; sandboxId = result.sandbox.id; } else { @@ -891,6 +1123,7 @@ async function wakeInstanceInternal( const resources = unwrapInstance( await withStep(step, () => getResourceConfigs(ctx, instanceId, projectId, includePrivate)) ); + const model = await getSandboxModelConfig(ctx, instanceId, projectId); const daytona = getDaytona(); step = 'get_sandbox'; const sandbox = await daytona.get(instance.sandboxId); @@ -899,10 +1132,12 @@ async function wakeInstanceInternal( unwrapInstance(await withStep(step, () => ensureSandboxStarted(sandbox))); step = 'upload_config'; unwrapInstance( - await withStep(step, () => syncGitHubAuth(sandbox, instance.clerkId, resources)) + await withStep(step, () => + syncGitHubAuth(ctx, sandbox, instanceId, instance.clerkId, resources) + ) ); step = 'upload_config'; - unwrapInstance(await withStep(step, () => uploadBtcaConfig(sandbox, resources))); + unwrapInstance(await withStep(step, () => uploadBtcaConfig(sandbox, resources, model))); step = 'start_btca'; serverUrl = unwrapInstance(await withStep(step, () => startBtcaServer(sandbox))).serverUrl; sandboxId = instance.sandboxId; @@ -946,11 +1181,8 @@ async function wakeInstanceInternal( context }); - await ctx.runMutation( - instanceMutations.setError, - withPrivateApiKey({ instanceId, errorMessage: message }) - ); - throw new WebUnhandledError({ message }); + const { errorMessage } = await setInstanceError(ctx, instanceId, error, message); + throw new WebUnhandledError({ message: errorMessage }); } } @@ -986,11 +1218,8 @@ async function stopInstanceInternal( return { stopped: true }; } catch (error) { const message = getErrorMessage(error); - await ctx.runMutation( - instanceMutations.setError, - withPrivateApiKey({ instanceId, errorMessage: message }) - ); - throw new WebUnhandledError({ message }); + const { errorMessage } = await setInstanceError(ctx, instanceId, error, message); + throw new WebUnhandledError({ message: errorMessage }); } } @@ -1018,7 +1247,9 @@ async function updateInstanceInternal( await updatePackages(sandbox); step = 'upload_config'; unwrapInstance( - await withStep(step, () => syncGitHubAuth(sandbox, instance.clerkId, resources)) + await withStep(step, () => + syncGitHubAuth(ctx, sandbox, instanceId, instance.clerkId, resources) + ) ); step = 'upload_config'; unwrapInstance(await withStep(step, () => uploadBtcaConfig(sandbox, resources))); @@ -1075,20 +1306,182 @@ async function updateInstanceInternal( return { updated: true }; } catch (error) { const message = getErrorMessage(error); + const { errorMessage } = await setInstanceError(ctx, instanceId, error, message); + throw new WebUnhandledError({ message: errorMessage }); + } +} + +export const migrate = privateAction({ + args: instanceArgs, + returns: v.object({ sandboxId: v.string() }), + handler: async (ctx, args) => { + const instance = await requireInstance(ctx, args.instanceId); + const previousSandboxId = instance.sandboxId; + const migrationStartedAt = Date.now(); + let sandbox: Sandbox | null = null; + let step = 'create_sandbox'; + await ctx.runMutation( - instanceMutations.setError, - withPrivateApiKey({ instanceId, errorMessage: message }) + instanceMutations.updateState, + withPrivateApiKey({ instanceId: args.instanceId, state: 'provisioning' }) + ); + await ctx.runMutation( + instanceMutations.setServerUrl, + withPrivateApiKey({ instanceId: args.instanceId, serverUrl: '' }) ); - throw new WebUnhandledError({ message }); + await ctx.runMutation( + instanceMutations.clearError, + withPrivateApiKey({ instanceId: args.instanceId }) + ); + + try { + const preparedSandbox = await createPreparedSandbox(ctx, args.instanceId, instance); + sandbox = preparedSandbox.sandbox; + const versions = preparedSandbox.versions; + + await ctx.runMutation( + instanceMutations.setProvisioned, + withPrivateApiKey({ + instanceId: args.instanceId, + sandboxId: sandbox.id, + snapshotName: BTCA_SNAPSHOT_NAME, + btcaVersion: versions.btcaVersion + }) + ); + await ctx.runMutation( + instanceMutations.touchActivity, + withPrivateApiKey({ instanceId: args.instanceId }) + ); + await ctx.scheduler.runAfter( + 0, + instances.actions.update, + withPrivateApiKey({ instanceId: args.instanceId }) + ); + + if (previousSandboxId) { + step = 'delete_old_sandbox'; + try { + const previousSandbox = await getDaytona().get(previousSandboxId); + await previousSandbox.delete(60); + } catch { + // Ignore cleanup errors for the previous sandbox. + } + } + + console.log('Sandbox migration completed', { + instanceId: args.instanceId, + previousSandboxId, + newSandboxId: sandbox.id, + durationMs: Date.now() - migrationStartedAt + }); + + return { sandboxId: sandbox.id }; + } catch (error) { + const message = formatUserMessage('migrate', step, getErrorMessage(error)); + console.error('Sandbox migration failed', { + instanceId: args.instanceId, + previousSandboxId, + newSandboxId: sandbox?.id, + step, + durationMs: Date.now() - migrationStartedAt, + error: getErrorDetails(error), + context: getErrorContext(error) + }); + const { errorMessage } = await setInstanceError(ctx, args.instanceId, error, message); + throw new WebUnhandledError({ message: errorMessage }); + } } +}); + +async function maybeScheduleSnapshotMigration( + ctx: ActionCtx, + instance: Doc<'instances'> +): Promise { + if (!requiresSnapshotMigration(instance)) { + return false; + } + + if (instance.state === 'unprovisioned' || instance.state === 'provisioning') { + return false; + } + + await ctx.runMutation( + instanceMutations.updateState, + withPrivateApiKey({ instanceId: instance._id, state: 'provisioning' }) + ); + await ctx.runMutation( + instanceMutations.setServerUrl, + withPrivateApiKey({ instanceId: instance._id, serverUrl: '' }) + ); + await ctx.runMutation( + instanceMutations.clearError, + withPrivateApiKey({ instanceId: instance._id }) + ); + await ctx.scheduler.runAfter( + 0, + instances.actions.migrate, + withPrivateApiKey({ instanceId: instance._id }) + ); + + return true; } export const wakeMyInstance = action({ - args: {}, + args: { + projectId: v.optional(v.id('projects')) + }, returns: v.object({ serverUrl: v.string() }), - handler: async (ctx): Promise<{ serverUrl: string }> => { + handler: async (ctx, args): Promise<{ serverUrl: string }> => { const instance = await requireAuthenticatedInstance(ctx); - return wakeInstanceInternal(ctx, instance._id); + return wakeInstanceInternal(ctx, instance._id, args.projectId); + } +}); + +export const applyProjectRuntimeConfig = action({ + args: { + projectId: v.id('projects') + }, + returns: v.object({ + applied: v.boolean(), + appliesOnWake: v.boolean() + }), + handler: async ( + ctx, + args + ): Promise<{ + applied: boolean; + appliesOnWake: boolean; + }> => { + const instance = await requireAuthenticatedInstance(ctx); + const project: Doc<'projects'> | null = await ctx.runQuery(internal.projects.getInternal, { + projectId: args.projectId + }); + + if (!project || project.instanceId !== instance._id) { + throw new WebValidationError({ message: 'Project not found', field: 'projectId' }); + } + + if (instance.state !== 'running' || !instance.sandboxId || !instance.serverUrl) { + return { + applied: false, + appliesOnWake: true + }; + } + + const result = await withInstanceRuntimeConfigLock( + instance._id.toString(), + async () => + (await ctx.runAction(internal.instances.actions.syncResources, { + instanceId: instance._id, + projectId: args.projectId, + includePrivate: true + })) as { synced: boolean } + ); + + return { + applied: result.synced, + appliesOnWake: !result.synced + }; } }); @@ -1117,8 +1510,33 @@ export const ensureInstanceExists = action({ const existing = await ctx.runQuery(instanceQueries.getByClerkId, {}); if (existing) { + let provisionScheduled = false; + if (existing.state === 'unprovisioned') { + await ctx.runMutation( + instanceMutations.updateState, + withPrivateApiKey({ instanceId: existing._id, state: 'provisioning' }) + ); + await ctx.runMutation( + instanceMutations.setServerUrl, + withPrivateApiKey({ instanceId: existing._id, serverUrl: '' }) + ); + await ctx.runMutation( + instanceMutations.clearError, + withPrivateApiKey({ instanceId: existing._id }) + ); + await ctx.scheduler.runAfter( + 0, + instances.actions.provision, + withPrivateApiKey({ instanceId: existing._id }) + ); + provisionScheduled = true; + } + const migrationScheduled = await maybeScheduleSnapshotMigration(ctx, existing); const isProvisioning = - existing.state === 'unprovisioned' || existing.state === 'provisioning'; + provisionScheduled || + migrationScheduled || + existing.state === 'unprovisioned' || + existing.state === 'provisioning'; return { instanceId: existing._id, status: isProvisioning ? 'provisioning' : 'exists' @@ -1153,8 +1571,33 @@ export const ensureInstanceExistsPrivate = privateAction({ }); if (existing) { + let provisionScheduled = false; + if (existing.state === 'unprovisioned') { + await ctx.runMutation( + instanceMutations.updateState, + withPrivateApiKey({ instanceId: existing._id, state: 'provisioning' }) + ); + await ctx.runMutation( + instanceMutations.setServerUrl, + withPrivateApiKey({ instanceId: existing._id, serverUrl: '' }) + ); + await ctx.runMutation( + instanceMutations.clearError, + withPrivateApiKey({ instanceId: existing._id }) + ); + await ctx.scheduler.runAfter( + 0, + instances.actions.provision, + withPrivateApiKey({ instanceId: existing._id }) + ); + provisionScheduled = true; + } + const migrationScheduled = await maybeScheduleSnapshotMigration(ctx, existing); const isProvisioning = - existing.state === 'unprovisioned' || existing.state === 'provisioning'; + provisionScheduled || + migrationScheduled || + existing.state === 'unprovisioned' || + existing.state === 'provisioning'; return { instanceId: existing._id, status: isProvisioning ? 'provisioning' : 'exists' @@ -1278,6 +1721,7 @@ export const syncResources = internalAction({ args.projectId, args.includePrivate ?? true ); + const model = await getSandboxModelConfig(ctx, args.instanceId, args.projectId); const daytona = getDaytona(); const sandbox = await daytona.get(instance.sandboxId); @@ -1286,8 +1730,8 @@ export const syncResources = internalAction({ } // Upload the config and reload the server - await syncGitHubAuth(sandbox, instance.clerkId, resources); - await uploadBtcaConfig(sandbox, resources); + await syncGitHubAuth(ctx, sandbox, args.instanceId, instance.clerkId, resources); + await uploadBtcaConfig(sandbox, resources, model); const previewAccess = await getPreviewAccessForSandbox( sandbox, instance.serverUrl ?? undefined diff --git a/apps/web/src/convex/instances/mutations.ts b/apps/web/src/convex/instances/mutations.ts index 20806fb7..71ec3fa7 100644 --- a/apps/web/src/convex/instances/mutations.ts +++ b/apps/web/src/convex/instances/mutations.ts @@ -61,6 +61,7 @@ export const setProvisioned = privateMutation({ args: { instanceId: v.id('instances'), sandboxId: v.string(), + snapshotName: v.string(), btcaVersion: v.optional(v.string()), storageUsedBytes: v.optional(v.number()) }, @@ -68,22 +69,36 @@ export const setProvisioned = privateMutation({ handler: async (ctx, args) => { const patch: { sandboxId: string; + snapshotName: string; state: 'stopped'; provisionedAt: number; + serverUrl: string; + errorKind: undefined; + errorMessage: undefined; + storageUsedBytes: number; btcaVersion?: string; - storageUsedBytes?: number; } = { sandboxId: args.sandboxId, + snapshotName: args.snapshotName, state: 'stopped', - provisionedAt: Date.now() + provisionedAt: Date.now(), + serverUrl: '', + errorKind: undefined, + errorMessage: undefined, + storageUsedBytes: args.storageUsedBytes ?? 0 }; if (args.btcaVersion !== undefined) { patch.btcaVersion = args.btcaVersion; } - if (args.storageUsedBytes !== undefined) { - patch.storageUsedBytes = args.storageUsedBytes; + const cachedResources = await ctx.db + .query('cachedResources') + .withIndex('by_instance', (q) => q.eq('instanceId', args.instanceId)) + .collect(); + + for (const resource of cachedResources) { + await ctx.db.delete(resource._id); } await ctx.db.patch(args.instanceId, patch); @@ -106,6 +121,7 @@ export const setServerUrl = privateMutation({ export const setError = privateMutation({ args: { instanceId: v.id('instances'), + errorKind: v.optional(v.union(v.literal('disk_full'), v.literal('generic'))), errorMessage: v.string() }, returns: v.null(), @@ -115,6 +131,7 @@ export const setError = privateMutation({ await ctx.db.patch(args.instanceId, { state: 'error', + errorKind: args.errorKind ?? 'generic', errorMessage: args.errorMessage }); @@ -137,7 +154,10 @@ export const clearError = privateMutation({ args: { instanceId: v.id('instances') }, returns: v.null(), handler: async (ctx, args) => { - await ctx.db.patch(args.instanceId, { errorMessage: undefined }); + await ctx.db.patch(args.instanceId, { + errorKind: undefined, + errorMessage: undefined + }); return null; } }); diff --git a/apps/web/src/convex/instances/queries.ts b/apps/web/src/convex/instances/queries.ts index 0b6de6a4..c0e5fd75 100644 --- a/apps/web/src/convex/instances/queries.ts +++ b/apps/web/src/convex/instances/queries.ts @@ -1,5 +1,6 @@ import { v } from 'convex/values'; import { Result } from 'better-result'; +import { BTCA_SNAPSHOT_NAME } from 'btca-sandbox/shared'; import { internalQuery, query } from '../_generated/server'; import { requireInstanceOwnershipResult, unwrapAuthResult } from '../authHelpers'; @@ -11,6 +12,7 @@ const instanceValidator = v.object({ _creationTime: v.number(), clerkId: v.string(), sandboxId: v.optional(v.string()), + snapshotName: v.optional(v.string()), state: v.union( v.literal('unprovisioned'), v.literal('provisioning'), @@ -22,6 +24,7 @@ const instanceValidator = v.object({ v.literal('error') ), serverUrl: v.optional(v.string()), + errorKind: v.optional(v.union(v.literal('disk_full'), v.literal('generic'))), errorMessage: v.optional(v.string()), btcaVersion: v.optional(v.string()), opencodeVersion: v.optional(v.string()), @@ -173,7 +176,9 @@ export const getStatus = query({ v.null(), v.object({ instance: instanceValidator, - cachedResources: v.array(cachedResourceValidator) + cachedResources: v.array(cachedResourceValidator), + expectedSnapshotName: v.string(), + migrationNeeded: v.boolean() }) ), handler: async (ctx) => { @@ -200,7 +205,9 @@ export const getStatus = query({ return { instance, - cachedResources: cachedResources.map((resource) => normalizeCachedResource(resource)) + cachedResources: cachedResources.map((resource) => normalizeCachedResource(resource)), + expectedSnapshotName: BTCA_SNAPSHOT_NAME, + migrationNeeded: instance.snapshotName !== BTCA_SNAPSHOT_NAME }; } }); diff --git a/apps/web/src/convex/mcp.ts b/apps/web/src/convex/mcp.ts index 1dfc2813..938912d8 100644 --- a/apps/web/src/convex/mcp.ts +++ b/apps/web/src/convex/mcp.ts @@ -11,6 +11,12 @@ import { instances } from './apiHelpers'; import type { ApiKeyValidationResult } from './clerkApiKeys'; import { getAvailableMcpResourceNames, toMcpVisibleResources } from './mcp/resourceContract.ts'; import { withPrivateApiKey } from './privateWrappers'; +import { + INSTANCE_DISK_FULL_APP_MESSAGE, + getInstanceErrorKind, + getUserFacingInstanceError +} from '../lib/instanceErrors'; +import { withInstanceRuntimeConfigLock } from './runtimeConfigLock.js'; import { toWebError, type WebError } from '../lib/result/errors'; const instanceActions = instances.actions; @@ -221,7 +227,13 @@ export const ask = action({ ...projectProperties, instanceState: instance.state }); - return { ok: false as const, error: 'Instance is in an error state' }; + return { + ok: false as const, + error: + instance.errorKind === 'disk_full' + ? INSTANCE_DISK_FULL_APP_MESSAGE + : 'Instance is in an error state' + }; } if (instance.state === 'provisioning' || instance.state === 'unprovisioned') { @@ -232,67 +244,83 @@ export const ask = action({ return { ok: false as const, error: 'Instance is still provisioning' }; } - let serverUrl = instance.serverUrl; - if (instance.state !== 'running' || !serverUrl) { - if (!instance.sandboxId) { - await trackAskFailure('Instance does not have a sandbox', projectProperties); - return { ok: false as const, error: 'Instance does not have a sandbox' }; - } - // Pass projectId to wake so it uses project-specific resources - const wakeResult = await ctx.runAction( - instanceActions.wake, - withPrivateApiKey({ + const startedAt = Date.now(); + let requestError: string | null = null; + const answerText = await withInstanceRuntimeConfigLock(instanceId.toString(), async () => { + let serverUrl = instance.serverUrl; + if (instance.state !== 'running' || !serverUrl) { + if (!instance.sandboxId) { + throw new Error('Instance does not have a sandbox'); + } + const wakeResult = await ctx.runAction( + instanceActions.wake, + withPrivateApiKey({ + instanceId, + projectId, + includePrivate: false + }) + ); + serverUrl = wakeResult.serverUrl; + if (!serverUrl) { + throw new Error('Failed to wake instance'); + } + } else { + await ctx.runAction(internal.instances.actions.syncResources, { instanceId, projectId, includePrivate: false - }) - ); - serverUrl = wakeResult.serverUrl; - if (!serverUrl) { - await trackAskFailure('Failed to wake instance', projectProperties); - return { ok: false as const, error: 'Failed to wake instance' }; + }); } - } else { - // Sandbox is already running - sync project-specific resources and reload config - await ctx.runAction(internal.instances.actions.syncResources, { - instanceId, - projectId, - includePrivate: false + + const previewAccess = await ctx.runAction(internal.instances.actions.getPreviewAccess, { + instanceId + }); + const response = await fetch(`${previewAccess.serverUrl}/question`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(previewAccess.previewToken + ? { 'x-daytona-preview-token': previewAccess.previewToken } + : {}) + }, + body: JSON.stringify({ + question, + resources, + project: effectiveProjectName, + quiet: true + }) }); - } - const previewAccess = await ctx.runAction(internal.instances.actions.getPreviewAccess, { - instanceId - }); - const startedAt = Date.now(); - const response = await fetch(`${previewAccess.serverUrl}/question`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(previewAccess.previewToken - ? { 'x-daytona-preview-token': previewAccess.previewToken } - : {}) - }, - body: JSON.stringify({ - question, - resources, - project: effectiveProjectName, - quiet: true - }) - }); + if (!response.ok) { + const errorText = await response.text(); + if (getInstanceErrorKind(errorText) === 'disk_full') { + await ctx.runMutation( + instanceMutations.setError, + withPrivateApiKey({ + instanceId, + errorKind: 'disk_full', + errorMessage: getUserFacingInstanceError(errorText, errorText) + }) + ); + } + throw new Error(errorText || `Server error: ${response.status}`); + } - if (!response.ok) { - const errorText = await response.text(); - await trackAskFailure(errorText || `Server error: ${response.status}`, { + const result = (await response.json()) as { answer?: string; text?: string }; + return result.answer ?? result.text ?? JSON.stringify(result); + }).catch(async (error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + requestError = errorMessage; + await trackAskFailure(errorMessage, { ...projectProperties, - status: response.status, durationMs: Date.now() - startedAt }); - return { ok: false as const, error: errorText || `Server error: ${response.status}` }; - } + return null; + }); - const result = (await response.json()) as { answer?: string; text?: string }; - const answerText = result.answer ?? result.text ?? JSON.stringify(result); + if (answerText == null) { + return { ok: false as const, error: requestError ?? 'Instance request failed' }; + } // Record the question/answer for the project await ctx.runMutation(internal.mcpInternal.recordQuestion, { @@ -653,7 +681,10 @@ export const sync = action({ await ctx.runQuery(internal.resources.listByProject, { projectId }) - ).filter((resource) => resource.visibility !== 'private'); + ).filter( + (resource: { name: string; url?: string; branch?: string; visibility?: string }) => + resource.visibility !== 'private' + ); const synced: string[] = []; const errors: string[] = []; @@ -662,7 +693,7 @@ export const sync = action({ // Process each resource in the config for (const localResource of config.resources) { const existingResource = existingResources.find( - (r) => r.name.toLowerCase() === localResource.name.toLowerCase() + (resource) => resource.name.toLowerCase() === localResource.name.toLowerCase() ); if (existingResource && existingResource.url) { diff --git a/apps/web/src/convex/mcpInternal.ts b/apps/web/src/convex/mcpInternal.ts index e7fc1f65..bbafe059 100644 --- a/apps/web/src/convex/mcpInternal.ts +++ b/apps/web/src/convex/mcpInternal.ts @@ -3,6 +3,7 @@ import { Result } from 'better-result'; import type { Id } from './_generated/dataModel'; import { internalMutation } from './_generated/server'; +import { isWebSandboxModelId } from '../lib/models/webSandboxModels.ts'; import { WebConflictError, WebValidationError, type WebError } from '../lib/result/errors'; type McpInternalResult = Result; @@ -11,6 +12,14 @@ const throwMcpInternalError = (error: WebError): never => { throw error; }; +const getGitProvider = (url: string): 'github' | 'generic' => { + try { + return new URL(url).hostname.toLowerCase() === 'github.com' ? 'github' : 'generic'; + } catch { + return 'generic'; + } +}; + /** * Internal mutation to create a project (used by MCP to avoid auth requirements) */ @@ -80,18 +89,15 @@ export const addResourceInternal = internalMutation({ }, returns: v.id('userResources'), handler: async (ctx, args): Promise> => { - // Check if resource with this name already exists for this project using compound index const existing = await ctx.db .query('userResources') - .withIndex('by_project_and_name', (q) => - q.eq('projectId', args.projectId).eq('name', args.name) - ) - .first(); + .withIndex('by_instance', (q) => q.eq('instanceId', args.instanceId)) + .collect(); - if (existing) { + if (existing.some((resource) => resource.name.toLowerCase() === args.name.toLowerCase())) { const result: McpInternalResult> = Result.err( new WebConflictError({ - message: `Resource "${args.name}" already exists in this project`, + message: `Resource "${args.name}" already exists in this instance`, conflict: args.name }) ); @@ -107,7 +113,7 @@ export const addResourceInternal = internalMutation({ branch: args.branch, searchPath: args.searchPath, specialNotes: args.specialNotes, - gitProvider: 'github', + gitProvider: getGitProvider(args.url), visibility: 'public', createdAt: Date.now() }); @@ -151,7 +157,7 @@ export const updateResourceInternal = internalMutation({ branch: args.branch, searchPath: args.searchPath, specialNotes: args.specialNotes, - gitProvider: 'github', + gitProvider: getGitProvider(args.url), visibility: 'public', authSource: undefined }); @@ -169,6 +175,12 @@ export const updateProjectModelInternal = internalMutation({ }, returns: v.null(), handler: async (ctx, args) => { + if (!isWebSandboxModelId(args.model)) { + throwMcpInternalError( + new WebValidationError({ message: 'Unsupported model', field: 'model' }) + ); + } + await ctx.db.patch(args.projectId, { model: args.model }); diff --git a/apps/web/src/convex/messages.ts b/apps/web/src/convex/messages.ts index be96447d..85eda214 100644 --- a/apps/web/src/convex/messages.ts +++ b/apps/web/src/convex/messages.ts @@ -47,6 +47,16 @@ const messageContentValidator = v.union( }) ); +const messageStatsValidator = v.object({ + durationMs: v.optional(v.number()), + inputTokens: v.optional(v.number()), + outputTokens: v.optional(v.number()), + cachedTokens: v.optional(v.number()), + totalTokens: v.optional(v.number()), + tokensPerSecond: v.optional(v.number()), + totalPriceUsd: v.optional(v.number()) +}); + /** * Add a user message to a thread (requires ownership) */ @@ -113,7 +123,8 @@ export const addAssistantMessage = mutation({ args: { threadId: v.id('threads'), content: messageContentValidator, - canceled: v.optional(v.boolean()) + canceled: v.optional(v.boolean()), + stats: v.optional(messageStatsValidator) }, returns: v.id('messages'), handler: async (ctx, args) => { @@ -124,6 +135,7 @@ export const addAssistantMessage = mutation({ role: 'assistant', content: args.content, canceled: args.canceled, + stats: args.stats, createdAt: Date.now() }); @@ -164,6 +176,7 @@ const messageValidator = v.object({ content: messageContentValidator, resources: v.optional(v.array(v.string())), canceled: v.optional(v.boolean()), + stats: v.optional(messageStatsValidator), createdAt: v.number() }); @@ -191,12 +204,13 @@ export const getByThread = query({ export const updateAssistantMessage = mutation({ args: { messageId: v.id('messages'), - content: messageContentValidator + content: messageContentValidator, + stats: v.optional(messageStatsValidator) }, returns: v.null(), handler: async (ctx, args) => { await unwrapAuthResult(await requireMessageOwnershipResult(ctx, args.messageId)); - await ctx.db.patch(args.messageId, { content: args.content }); + await ctx.db.patch(args.messageId, { content: args.content, stats: args.stats }); return null; } }); diff --git a/apps/web/src/convex/projects.ts b/apps/web/src/convex/projects.ts index b3388c72..f7b0f59d 100644 --- a/apps/web/src/convex/projects.ts +++ b/apps/web/src/convex/projects.ts @@ -6,6 +6,7 @@ import type { Doc, Id } from './_generated/dataModel'; import { internalQuery, mutation, query } from './_generated/server'; import { AnalyticsEvents } from './analyticsEvents'; import { getAuthenticatedInstanceResult, unwrapAuthResult } from './authHelpers'; +import { isWebSandboxModelId } from '../lib/models/webSandboxModels.ts'; import { WebConflictError, WebValidationError, type WebError } from '../lib/result/errors'; // Project validator @@ -28,6 +29,12 @@ const throwProjectError = (error: WebError): never => { const projectNotFoundError = (message: string): WebValidationError => new WebValidationError({ message, field: 'project' }); +const validateProjectModel = (model?: string) => { + if (model && !isWebSandboxModelId(model)) { + throwProjectError(new WebValidationError({ message: 'Unsupported model', field: 'model' })); + } +}; + type ProjectDb = { get(id: Id<'projects'>): Promise | null>; }; @@ -138,6 +145,7 @@ export const create = mutation({ returns: v.id('projects'), handler: async (ctx, args) => { const instance = await unwrapAuthResult(await getAuthenticatedInstanceResult(ctx)); + validateProjectModel(args.model); const existing = await ctx.db .query('projects') .withIndex('by_instance_and_name', (q) => @@ -236,6 +244,7 @@ export const updateModel = mutation({ returns: v.null(), handler: async (ctx, args) => { const instance = await unwrapAuthResult(await getAuthenticatedInstanceResult(ctx)); + validateProjectModel(args.model); const projectResult = await requireProjectOwnershipResult(ctx, args.projectId, instance._id); const project = Result.match(projectResult, { ok: (value) => value, diff --git a/apps/web/src/convex/resourceActions.ts b/apps/web/src/convex/resourceActions.ts index 790379ff..bf4c6dd7 100644 --- a/apps/web/src/convex/resourceActions.ts +++ b/apps/web/src/convex/resourceActions.ts @@ -6,10 +6,16 @@ import { v } from 'convex/values'; import { internal } from './_generated/api'; import type { Id } from './_generated/dataModel'; -import { action } from './_generated/server'; +import { action, type ActionCtx } from './_generated/server'; import { AnalyticsEvents } from './analyticsEvents'; import { instances } from './apiHelpers'; -import { inspectGitHubConnectionForClerkUser } from './githubAuth'; +import { + ensureGitHubBranch, + fetchGitHubRepo, + getRepoFullName, + parseGitHubRepoRef, + resolveAccessibleRepo +} from './githubApp'; import { WebAuthError, WebUnhandledError, WebValidationError } from '../lib/result/errors'; type InternalResources = { @@ -29,7 +35,7 @@ type InternalResources = { specialNotes?: string; gitProvider?: 'github' | 'generic'; visibility?: 'public' | 'private'; - authSource?: 'clerk_github_oauth'; + authSource?: 'clerk_github_oauth' | 'github_app'; }, Id<'userResources'> >; @@ -50,32 +56,30 @@ type InternalAnalytics = { >; }; +type GitHubInstallationRecord = { + installationId: number; + accountLogin: string; + repositorySelection: 'all' | 'selected'; + repositoryNames: string[]; + status: 'active' | 'suspended' | 'deleted'; +}; + const resourcesInternal = internal as unknown as { resources: InternalResources; githubConnections: { - upsertForInstance: FunctionReference< - 'mutation', + getByOwner: FunctionReference< + 'query', 'internal', { instanceId: Id<'instances'>; - clerkUserId: string; - githubUserId?: number; - githubLogin?: string; - scopes: string[]; - status: 'connected' | 'missing_scope' | 'disconnected'; - connectedAt?: number; + accountLogin: string; }, - Id<'githubConnections'> + GitHubInstallationRecord[] >; }; analytics: InternalAnalytics; }; -type RepoRef = { - owner: string; - repo: string; -}; - type GitHubRepoResponse = { private: boolean; default_branch: string; @@ -84,28 +88,6 @@ type GitHubRepoResponse = { const NPM_PACKAGE_SEGMENT_REGEX = /^[a-z0-9][a-z0-9._-]*$/; const NPM_VERSION_OR_TAG_REGEX = /^[^\s/]+$/; -const parseGitHubRepoRef = (url: string): RepoRef | null => { - try { - const parsed = new URL(url); - if (parsed.hostname.toLowerCase() !== 'github.com') return null; - const [owner, repo] = parsed.pathname - .split('/') - .filter(Boolean) - .slice(0, 2) - .map((segment) => segment.replace(/\.git$/, '')); - if (!owner || !repo) return null; - return { owner, repo }; - } catch { - return null; - } -}; - -const getGitHubHeaders = (token?: string) => ({ - Accept: 'application/vnd.github+json', - 'User-Agent': 'btca-web', - ...(token ? { Authorization: `Bearer ${token}` } : {}) -}); - const isValidNpmPackageName = (name: string) => { if (name.startsWith('@')) { const parts = name.split('/'); @@ -120,33 +102,18 @@ const isValidNpmPackageName = (name: string) => { return !name.includes('/') && NPM_PACKAGE_SEGMENT_REGEX.test(name); }; -const fetchGitHubRepo = async (repoRef: RepoRef, token?: string) => - fetch(`https://api.github.com/repos/${repoRef.owner}/${repoRef.repo}`, { - headers: getGitHubHeaders(token) +const getOwnerInstallations = async (ctx: ActionCtx, instanceId: Id<'instances'>, owner: string) => + await ctx.runQuery(resourcesInternal.githubConnections.getByOwner, { + instanceId, + accountLogin: owner.toLowerCase() }); -const ensureGitHubBranch = async (repoRef: RepoRef, branch: string, token?: string) => { - const response = await fetch( - `https://api.github.com/repos/${repoRef.owner}/${repoRef.repo}/branches/${encodeURIComponent(branch)}`, - { - headers: getGitHubHeaders(token) - } - ); - - if (response.ok) return; - if (response.status === 404) { - throw new WebValidationError({ - message: `Branch "${branch}" was not found on ${repoRef.owner}/${repoRef.repo}`, - field: 'branch' - }); - } - - throw new WebUnhandledError({ - message: `GitHub branch lookup failed with status ${response.status}` - }); -}; - -const resolveGitMetadata = async (clerkUserId: string, url: string, branch: string) => { +const resolveGitMetadata = async ( + ctx: ActionCtx, + instanceId: Id<'instances'>, + url: string, + branch: string +) => { const repoRef = parseGitHubRepoRef(url); if (!repoRef) { return { @@ -165,7 +132,7 @@ const resolveGitMetadata = async (clerkUserId: string, url: string, branch: stri branch: resolvedBranch, gitProvider: 'github' as const, visibility: repo.private ? ('private' as const) : ('public' as const), - authSource: repo.private ? ('clerk_github_oauth' as const) : undefined + authSource: repo.private ? ('github_app' as const) : undefined }; } @@ -175,46 +142,47 @@ const resolveGitMetadata = async (clerkUserId: string, url: string, branch: stri }); } - const connection = await inspectGitHubConnectionForClerkUser(clerkUserId); - if (connection.status === 'disconnected') { + const repoFullName = getRepoFullName(repoRef); + const activeInstallations = (await getOwnerInstallations(ctx, instanceId, repoRef.owner)).filter( + (installation) => installation.status === 'active' + ); + + if (activeInstallations.length === 0) { throw new WebAuthError({ - message: 'Connect GitHub in your profile before adding private GitHub repositories.', + message: `Connect GitHub and install the btca GitHub App on ${repoRef.owner} before adding private repositories.`, code: 'UNAUTHORIZED' }); } - if (connection.status === 'missing_scope') { - throw new WebAuthError({ - message: 'Reconnect GitHub with private repository access before adding private repos.', - code: 'FORBIDDEN' - }); - } + for (const installation of activeInstallations) { + if ( + installation.repositorySelection === 'selected' && + installation.repositoryNames.length > 0 && + !installation.repositoryNames.includes(repoFullName) + ) { + continue; + } - const response = await fetchGitHubRepo(repoRef, connection.token); - if (response.status === 404) { - throw new WebValidationError({ - message: `Repository "${repoRef.owner}/${repoRef.repo}" was not found or you do not have access to it.`, - field: 'url' - }); - } + const accessibleRepo = await resolveAccessibleRepo(installation.installationId, repoRef); + if (!accessibleRepo) { + continue; + } - if (!response.ok) { - throw new WebUnhandledError({ - message: `GitHub repository lookup failed with status ${response.status}` - }); - } + const resolvedBranch = branch.trim() || accessibleRepo.repo.default_branch; + await ensureGitHubBranch(repoRef, resolvedBranch, accessibleRepo.token); - const repo = (await response.json()) as GitHubRepoResponse; - const resolvedBranch = branch.trim() || repo.default_branch; - await ensureGitHubBranch(repoRef, resolvedBranch, connection.token); + return { + branch: resolvedBranch, + gitProvider: 'github' as const, + visibility: accessibleRepo.repo.private ? ('private' as const) : ('public' as const), + authSource: accessibleRepo.repo.private ? ('github_app' as const) : undefined + }; + } - return { - connection, - branch: resolvedBranch, - gitProvider: 'github' as const, - visibility: repo.private ? ('private' as const) : ('public' as const), - authSource: repo.private ? ('clerk_github_oauth' as const) : undefined - }; + throw new WebAuthError({ + message: `Grant the ${repoFullName} repository to the btca GitHub App before adding it.`, + code: 'FORBIDDEN' + }); }; export const addCustomResource = action({ @@ -348,18 +316,7 @@ export const addCustomResource = action({ }); } - const metadata = await resolveGitMetadata(identity.subject, args.url, args.branch ?? 'main'); - if ('connection' in metadata && metadata.connection) { - await ctx.runMutation(resourcesInternal.githubConnections.upsertForInstance, { - instanceId: instance._id, - clerkUserId: identity.subject, - githubUserId: metadata.connection.githubUserId, - githubLogin: metadata.connection.githubLogin, - scopes: metadata.connection.scopes, - status: metadata.connection.status, - connectedAt: metadata.connection.connectedAt - }); - } + const metadata = await resolveGitMetadata(ctx, instance._id, args.url, args.branch ?? 'main'); const resourceId = await ctx.runMutation( resourcesInternal.resources.addCustomResourceInternal, { diff --git a/apps/web/src/convex/resources.ts b/apps/web/src/convex/resources.ts index 29268369..d8956292 100644 --- a/apps/web/src/convex/resources.ts +++ b/apps/web/src/convex/resources.ts @@ -40,13 +40,10 @@ const getGitProvider = (url?: string): 'github' | 'generic' => { const shouldIncludeResource = ( resource: { visibility?: 'public' | 'private'; - authSource?: 'clerk_github_oauth'; + authSource?: 'clerk_github_oauth' | 'github_app'; }, includePrivate: boolean -) => - includePrivate || - resource.visibility !== 'private' || - resource.authSource !== 'clerk_github_oauth'; +) => includePrivate || resource.visibility !== 'private'; const getStoredResourceType = (resource: { type?: 'git' | 'npm'; package?: string }) => resource.type === 'npm' || resource.package ? 'npm' : 'git'; @@ -67,7 +64,7 @@ const normalizeUserResource = < specialNotes?: string; gitProvider?: 'github' | 'generic'; visibility?: 'public' | 'private'; - authSource?: 'clerk_github_oauth'; + authSource?: 'clerk_github_oauth' | 'github_app'; createdAt: number; } >( @@ -99,7 +96,7 @@ const toCustomResource = (resource: { specialNotes?: string; gitProvider?: 'github' | 'generic'; visibility?: 'public' | 'private'; - authSource?: 'clerk_github_oauth'; + authSource?: 'clerk_github_oauth' | 'github_app'; }): { name: string; displayName: string; @@ -112,7 +109,7 @@ const toCustomResource = (resource: { specialNotes?: string; gitProvider?: 'github' | 'generic'; visibility?: 'public' | 'private'; - authSource?: 'clerk_github_oauth'; + authSource?: 'clerk_github_oauth' | 'github_app'; isGlobal: false; } => { const type = getStoredResourceType(resource); @@ -161,7 +158,7 @@ const customResourceValidator = v.object({ specialNotes: v.optional(v.string()), gitProvider: v.optional(v.union(v.literal('github'), v.literal('generic'))), visibility: v.optional(v.union(v.literal('public'), v.literal('private'))), - authSource: v.optional(v.literal('clerk_github_oauth')), + authSource: v.optional(v.union(v.literal('clerk_github_oauth'), v.literal('github_app'))), isGlobal: v.literal(false) }); @@ -180,7 +177,7 @@ const userResourceValidator = v.object({ specialNotes: v.optional(v.string()), gitProvider: v.optional(v.union(v.literal('github'), v.literal('generic'))), visibility: v.optional(v.union(v.literal('public'), v.literal('private'))), - authSource: v.optional(v.literal('clerk_github_oauth')), + authSource: v.optional(v.union(v.literal('clerk_github_oauth'), v.literal('github_app'))), createdAt: v.number() }); @@ -279,7 +276,10 @@ export const listAvailable = query({ }); /** - * Check if a resource name exists within a specific project (case-insensitive) + * Check if a resource name already exists anywhere on the instance (case-insensitive). + * + * Resource cache directories are keyed by resource name inside btca, so allowing the same name + * across projects can cause one project's repo checkout to be reused for another project. */ export const resourceExistsInProject = internalQuery({ args: { @@ -288,12 +288,19 @@ export const resourceExistsInProject = internalQuery({ }, returns: v.boolean(), handler: async (ctx, args) => { - const projectResources = await ctx.db + const project = await ctx.db.get(args.projectId); + if (!project) { + return false; + } + + const instanceResources = await ctx.db .query('userResources') - .withIndex('by_project', (q) => q.eq('projectId', args.projectId)) + .withIndex('by_instance', (q) => q.eq('instanceId', project.instanceId)) .collect(); - return projectResources.some((r) => r.name.toLowerCase() === args.name.toLowerCase()); + return instanceResources.some( + (resource) => resource.name.toLowerCase() === args.name.toLowerCase() + ); } }); @@ -505,7 +512,7 @@ export const addCustomResourceInternal = internalMutation({ specialNotes: v.optional(v.string()), gitProvider: v.optional(v.union(v.literal('github'), v.literal('generic'))), visibility: v.optional(v.union(v.literal('public'), v.literal('private'))), - authSource: v.optional(v.literal('clerk_github_oauth')) + authSource: v.optional(v.union(v.literal('clerk_github_oauth'), v.literal('github_app'))) }, returns: v.id('userResources'), handler: async (ctx, args) => { diff --git a/apps/web/src/convex/runtimeConfigLock.ts b/apps/web/src/convex/runtimeConfigLock.ts new file mode 100644 index 00000000..64a1828d --- /dev/null +++ b/apps/web/src/convex/runtimeConfigLock.ts @@ -0,0 +1,26 @@ +const runtimeConfigLocks = new Map>(); + +export const withInstanceRuntimeConfigLock = async ( + instanceId: string, + task: () => Promise +): Promise => { + const previous = runtimeConfigLocks.get(instanceId) ?? Promise.resolve(); + let release = () => {}; + const current = new Promise((resolve) => { + release = resolve; + }); + const queued = previous.then(() => current); + + runtimeConfigLocks.set(instanceId, queued); + + await previous; + + try { + return await task(); + } finally { + release(); + if (runtimeConfigLocks.get(instanceId) === queued) { + runtimeConfigLocks.delete(instanceId); + } + } +}; diff --git a/apps/web/src/convex/schema.ts b/apps/web/src/convex/schema.ts index 40c237e3..512b15bd 100644 --- a/apps/web/src/convex/schema.ts +++ b/apps/web/src/convex/schema.ts @@ -35,10 +35,21 @@ const messageContentValidator = v.union( }) ); +const messageStatsValidator = v.object({ + durationMs: v.optional(v.number()), + inputTokens: v.optional(v.number()), + outputTokens: v.optional(v.number()), + cachedTokens: v.optional(v.number()), + totalTokens: v.optional(v.number()), + tokensPerSecond: v.optional(v.number()), + totalPriceUsd: v.optional(v.number()) +}); + export default defineSchema({ instances: defineTable({ clerkId: v.string(), sandboxId: v.optional(v.string()), + snapshotName: v.optional(v.string()), state: v.union( v.literal('unprovisioned'), v.literal('provisioning'), @@ -50,6 +61,7 @@ export default defineSchema({ v.literal('error') ), serverUrl: v.optional(v.string()), + errorKind: v.optional(v.union(v.literal('disk_full'), v.literal('generic'))), errorMessage: v.optional(v.string()), btcaVersion: v.optional(v.string()), opencodeVersion: v.optional(v.string()), @@ -122,7 +134,7 @@ export default defineSchema({ specialNotes: v.optional(v.string()), gitProvider: v.optional(v.union(v.literal('github'), v.literal('generic'))), visibility: v.optional(v.union(v.literal('public'), v.literal('private'))), - authSource: v.optional(v.literal('clerk_github_oauth')), + authSource: v.optional(v.union(v.literal('clerk_github_oauth'), v.literal('github_app'))), createdAt: v.number() }) .index('by_instance', ['instanceId']) @@ -143,6 +155,30 @@ export default defineSchema({ .index('by_instance', ['instanceId']) .index('by_clerk_user_id', ['clerkUserId']), + githubInstallations: defineTable({ + instanceId: v.id('instances'), + clerkUserId: v.string(), + installationId: v.number(), + accountLogin: v.string(), + accountType: v.union(v.literal('User'), v.literal('Organization')), + targetType: v.union(v.literal('User'), v.literal('Organization')), + repositorySelection: v.union(v.literal('all'), v.literal('selected')), + repositoryIds: v.array(v.number()), + repositoryNames: v.array(v.string()), + contentsPermission: v.optional(v.string()), + metadataPermission: v.optional(v.string()), + htmlUrl: v.optional(v.string()), + status: v.union(v.literal('active'), v.literal('suspended'), v.literal('deleted')), + connectedAt: v.number(), + lastSyncedAt: v.number(), + suspendedAt: v.optional(v.number()) + }) + .index('by_instance', ['instanceId']) + .index('by_clerk_user_id', ['clerkUserId']) + .index('by_installation_id', ['installationId']) + .index('by_instance_and_installation', ['instanceId', 'installationId']) + .index('by_instance_and_account_login', ['instanceId', 'accountLogin']), + threads: defineTable({ instanceId: v.id('instances'), projectId: v.optional(v.id('projects')), @@ -159,6 +195,7 @@ export default defineSchema({ content: messageContentValidator, resources: v.optional(v.array(v.string())), canceled: v.optional(v.boolean()), + stats: v.optional(messageStatsValidator), createdAt: v.number() }).index('by_thread', ['threadId']), diff --git a/apps/web/src/convex/threads.ts b/apps/web/src/convex/threads.ts index ae78a912..2c5f6c84 100644 --- a/apps/web/src/convex/threads.ts +++ b/apps/web/src/convex/threads.ts @@ -106,6 +106,16 @@ const messageContentValidator = v.union( }) ); +const messageStatsValidator = v.object({ + durationMs: v.optional(v.number()), + inputTokens: v.optional(v.number()), + outputTokens: v.optional(v.number()), + cachedTokens: v.optional(v.number()), + totalTokens: v.optional(v.number()), + tokensPerSecond: v.optional(v.number()), + totalPriceUsd: v.optional(v.number()) +}); + const messageValidator = v.object({ _id: v.id('messages'), _creationTime: v.number(), @@ -114,6 +124,7 @@ const messageValidator = v.object({ content: messageContentValidator, resources: v.optional(v.array(v.string())), canceled: v.optional(v.boolean()), + stats: v.optional(messageStatsValidator), createdAt: v.number() }); diff --git a/apps/web/src/convex/usage.ts b/apps/web/src/convex/usage.ts index 0945934d..7d62a8ca 100644 --- a/apps/web/src/convex/usage.ts +++ b/apps/web/src/convex/usage.ts @@ -4,15 +4,22 @@ import { Result } from 'better-result'; import type { Doc } from './_generated/dataModel.js'; import { internal } from './_generated/api.js'; -import { action, type ActionCtx } from './_generated/server.js'; +import { action, internalAction, type ActionCtx } from './_generated/server.js'; import { AnalyticsEvents } from './analyticsEvents.js'; -import { instances } from './apiHelpers.js'; +import { instances, scheduled } from './apiHelpers.js'; import { requireInstanceOwnershipActionResult, unwrapAuthResult } from './authHelpers.js'; import { withPrivateApiKey } from './privateWrappers.js'; +import { + PRO_AI_BUDGET_MICROS, + getPreflightAiBudgetMicros, + totalAiBudgetMicros +} from '../lib/billing/aiBudget'; +import { getWebSandboxModel } from '../lib/models/webSandboxModels'; import { WebConfigMissingError, WebExternalDependencyError, WebUnhandledError, + WebValidationError, type WebError } from '../lib/result/errors.js'; @@ -28,18 +35,16 @@ type UsageCheckResult = ok: boolean; reason: string | null; metrics: { - tokensIn: FeatureMetrics; - tokensOut: FeatureMetrics; - sandboxHours: FeatureMetrics; + aiBudget: FeatureMetrics; }; inputTokens: number; - sandboxUsageHours: number; + requiredBudgetMicros: number; + modelId: string; customerId: string; }; type FinalizeUsageResult = { - outputTokens: number; - sandboxUsageHours: number; + chargedBudgetMicros: number; customerId: string; }; @@ -54,12 +59,13 @@ type BillingSummaryResult = { status: 'active' | 'trialing' | 'canceled' | 'none'; currentPeriodEnd: number | undefined; canceledAt: number | undefined; - customer: { name: null; email: null }; + customer: { + name: string | null; + email: string | null; + }; paymentMethod: unknown; usage: { - tokensIn: UsageMetricDisplay; - tokensOut: UsageMetricDisplay; - sandboxHours: UsageMetricDisplay; + aiBudget: UsageMetricDisplay; }; freeMessages?: { used: number; @@ -80,12 +86,9 @@ type SubscriptionSnapshot = { canceledAt?: number | null; }; -const SANDBOX_IDLE_MINUTES = 2; const CHARS_PER_TOKEN = 4; const FEATURE_IDS = { - tokensIn: 'tokens_in', - tokensOut: 'tokens_out', - sandboxHours: 'sandbox_hours', + aiBudget: 'ai_budget', chatMessages: 'chat_messages' } as const; @@ -135,21 +138,6 @@ function estimateTokensFromText(text: string): number { return Math.max(1, Math.ceil(trimmed.length / CHARS_PER_TOKEN)); } -function estimateTokensFromChars(chars: number): number { - if (!Number.isFinite(chars) || chars <= 0) return 0; - return Math.max(1, Math.ceil(chars / CHARS_PER_TOKEN)); -} - -function estimateSandboxUsageHours(params: { lastActiveAt?: number | null; now: number }): number { - const maxWindowMs = SANDBOX_IDLE_MINUTES * 60 * 1000; - if (!params.lastActiveAt) { - return maxWindowMs / (60 * 60 * 1000); - } - const deltaMs = Math.max(0, params.now - params.lastActiveAt); - const cappedMs = Math.min(deltaMs, maxWindowMs); - return cappedMs / (60 * 60 * 1000); -} - function clampPercent(value: number): number { if (!Number.isFinite(value)) return 0; return Math.min(100, Math.max(0, value)); @@ -157,6 +145,8 @@ function clampPercent(value: number): number { type AutumnCustomer = { id: string; + name?: string | null; + email?: string | null; products?: { id?: string; status?: string; @@ -177,6 +167,77 @@ const unwrapUsage = (result: UsageResult): T => { }); }; +const toUsageMetric = (args: { usage: number; included: number; balance: number }) => { + const usedPct = args.included > 0 ? clampPercent((args.usage / args.included) * 100) : 0; + const remainingPct = clampPercent(100 - usedPct); + return { + usedPct, + remainingPct, + isDepleted: remainingPct <= 0 || args.balance <= 0 + }; +}; + +const customerExpand = ['payment_method'] as const; +const checkoutSuccessPath = '/app/checkout/success'; +const checkoutCancelPath = '/app/checkout/cancel'; +const billingReturnPath = '/app/settings/billing'; + +const isAutumnNotFound = (args: { message?: string; statusCode?: number }) => + args.statusCode === 404 || args.message?.toLowerCase().includes('not found') === true; + +const toAutumnCustomer = ( + customer: { + id: string | null; + name: string | null; + email: string | null; + products: { + id?: string; + status?: string; + current_period_end?: number | null; + canceled_at?: number | null; + }[]; + payment_method?: unknown; + }, + fallbackId: string +): AutumnCustomer => ({ + id: customer.id ?? fallbackId, + name: customer.name, + email: customer.email, + products: customer.products ?? [], + payment_method: customer.payment_method +}); + +async function resolveProUsageMetrics(args: { + customerId: string; + requiredBalance?: number; +}): Promise> { + return await checkFeature({ + customerId: args.customerId, + featureId: FEATURE_IDS.aiBudget, + requiredBalance: args.requiredBalance + }); +} + +async function getResolvedModelId(args: { + ctx: ActionCtx; + instanceId: Doc<'instances'>['_id']; + projectId?: Doc<'projects'>['_id']; +}) { + if (!args.projectId) { + return getWebSandboxModel().id; + } + + const project = await args.ctx.runQuery(internal.projects.getInternal, { + projectId: args.projectId + }); + + if (!project || project.instanceId !== args.instanceId) { + throw new WebValidationError({ message: 'Project not found', field: 'projectId' }); + } + + return getWebSandboxModel(project.model).id; +} + async function getOrCreateCustomer(user: { clerkId: string; email?: string | null; @@ -189,12 +250,21 @@ async function getOrCreateCustomer(user: { const autumn = autumnResult.value; - const fetchCustomer = async (customerId: string): Promise> => { + const fetchCustomer = async (): Promise> => { try { - const customerPayload = await autumn.customers.get(customerId, { - expand: ['payment_method'] + const customerPayload = await autumn.customers.get(user.clerkId, { + expand: customerExpand }); if (customerPayload.error) { + if ( + isAutumnNotFound({ + message: customerPayload.error.message, + statusCode: customerPayload.statusCode + }) + ) { + return Result.ok(null); + } + return Result.err( new WebExternalDependencyError({ message: customerPayload.error.message ?? 'Failed to fetch Autumn customer', @@ -202,41 +272,44 @@ async function getOrCreateCustomer(user: { }) ); } - const id = customerPayload.data?.id ?? customerId; - return Result.ok({ - id, - products: customerPayload.data?.products ?? [], - payment_method: customerPayload.data?.payment_method - }); + + return Result.ok(toAutumnCustomer(customerPayload.data, user.clerkId)); } catch (error) { return toExternalError(error, 'Failed to fetch Autumn customer', 'Autumn'); } }; try { + const existingCustomerResult = await fetchCustomer(); + if (Result.isError(existingCustomerResult)) { + return Result.err(existingCustomerResult.error); + } + if (existingCustomerResult.value) { + return Result.ok(existingCustomerResult.value); + } + const createPayload = await autumn.customers.create({ id: user.clerkId, email: user.email ?? undefined, - name: user.name ?? undefined + name: user.name ?? undefined, + expand: customerExpand }); if (!createPayload.error) { - const customerId = createPayload.data?.id ?? user.clerkId; - return await fetchCustomer(customerId); + return Result.ok(toAutumnCustomer(createPayload.data, user.clerkId)); } - const message = createPayload.error?.message ?? 'Failed to create Autumn customer'; - const alreadyExists = message.toLowerCase().includes('already'); - if (!alreadyExists) { - return Result.err( - new WebExternalDependencyError({ - message, - dependency: 'Autumn' - }) - ); + const concurrentFetchResult = await fetchCustomer(); + if (!Result.isError(concurrentFetchResult) && concurrentFetchResult.value) { + return Result.ok(concurrentFetchResult.value); } - return await fetchCustomer(user.clerkId); + return Result.err( + new WebExternalDependencyError({ + message: createPayload.error?.message ?? 'Failed to create Autumn customer', + dependency: 'Autumn' + }) + ); } catch (error) { return toExternalError(error, 'Failed to create Autumn customer', 'Autumn'); } @@ -315,60 +388,72 @@ async function trackUsage(args: { } } -async function createCheckoutSessionUrl(args: { - autumnClient: Autumn; - baseUrl: string; +async function resetAiBudgetBalance(args: { customerId: string; -}): Promise> { + remaining: number; +}): Promise> { + const autumnResult = getAutumnClientResult(); + if (Result.isError(autumnResult)) { + return Result.err(autumnResult.error); + } + const autumn = autumnResult.value; try { - const payload = await args.autumnClient.checkout({ + const result = await autumn.v2.balances.update({ customer_id: args.customerId, - product_id: 'btca_pro', - success_url: `${args.baseUrl}/app/checkout/success`, - checkout_session_params: { - cancel_url: `${args.baseUrl}/app/checkout/cancel` - } + feature_id: FEATURE_IDS.aiBudget, + remaining: args.remaining }); - - if (payload.error) { + if (result.error) { return Result.err( new WebExternalDependencyError({ - message: payload.error.message ?? 'Failed to create checkout session', + message: result.error.message ?? 'Failed to reset Autumn AI budget balance', dependency: 'Autumn' }) ); } + return Result.ok(undefined); + } catch (error) { + return toExternalError(error, 'Failed to reset Autumn AI budget balance', 'Autumn'); + } +} - if (payload.data?.url) { - return Result.ok(payload.data.url); - } - - const attachPayload = await args.autumnClient.attach({ +async function createCheckoutSessionUrl(args: { + autumnClient: Autumn; + baseUrl: string; + customerId: string; + customerData?: { + email?: string | null; + name?: string | null; + }; +}): Promise> { + try { + const payload = await args.autumnClient.attach({ customer_id: args.customerId, product_id: 'btca_pro', - success_url: `${args.baseUrl}/app/checkout/success` + force_checkout: true, + success_url: `${args.baseUrl}${checkoutSuccessPath}`, + customer_data: + args.customerData?.email || args.customerData?.name + ? { + email: args.customerData.email ?? undefined, + name: args.customerData.name ?? undefined + } + : undefined, + checkout_session_params: { + cancel_url: `${args.baseUrl}${checkoutCancelPath}` + } }); - if (attachPayload.error) { - return Result.err( - new WebExternalDependencyError({ - message: attachPayload.error.message ?? 'Failed to attach checkout session', - dependency: 'Autumn' - }) - ); - } - - const checkoutUrl = attachPayload.data?.checkout_url; - if (!checkoutUrl) { + if (payload.error) { return Result.err( new WebExternalDependencyError({ - message: 'Checkout session created but no checkout URL was returned', + message: payload.error.message ?? 'Failed to create checkout session', dependency: 'Autumn' }) ); } - return Result.ok(checkoutUrl); + return Result.ok(payload.data?.checkout_url ?? `${args.baseUrl}${checkoutSuccessPath}`); } catch (error) { return toExternalError(error, 'Failed to create checkout session', 'Autumn'); } @@ -381,7 +466,7 @@ async function createBillingPortalSessionUrl(args: { }): Promise> { try { const payload = await args.autumnClient.customers.billingPortal(args.customerId, { - return_url: `${args.baseUrl}/app/settings/billing` + return_url: `${args.baseUrl}${billingReturnPath}` }); if (payload.error) { @@ -542,7 +627,8 @@ export const ensureUsageAvailable = action({ args: { instanceId: v.id('instances'), question: v.string(), - resources: v.array(v.string()) + resources: v.array(v.string()), + projectId: v.optional(v.id('projects')) }, returns: v.union( v.object({ @@ -553,12 +639,11 @@ export const ensureUsageAvailable = action({ ok: v.boolean(), reason: v.union(v.string(), v.null()), metrics: v.object({ - tokensIn: featureMetricsValidator, - tokensOut: featureMetricsValidator, - sandboxHours: featureMetricsValidator + aiBudget: featureMetricsValidator }), inputTokens: v.number(), - sandboxUsageHours: v.number(), + requiredBudgetMicros: v.number(), + modelId: v.string(), customerId: v.string() }) ), @@ -579,6 +664,11 @@ export const ensureUsageAvailable = action({ : undefined) }) ); + const modelId = await getResolvedModelId({ + ctx, + instanceId: instance._id, + projectId: args.projectId + }); const activeProduct = getActiveProduct(autumnCustomer.products); await syncSubscriptionState(ctx, instance, getSubscriptionSnapshot(activeProduct)); if (!activeProduct) { @@ -621,71 +711,39 @@ export const ensureUsageAvailable = action({ ok: true, reason: null, metrics: { - tokensIn: { usage: 0, balance: 0, included: 0 }, - tokensOut: { usage: 0, balance: 0, included: 0 }, - sandboxHours: { usage: 0, balance: 0, included: 0 } + aiBudget: { usage: 0, balance: 0, included: PRO_AI_BUDGET_MICROS } }, inputTokens: 0, - sandboxUsageHours: 0, + requiredBudgetMicros: 0, + modelId, customerId: autumnCustomer.id ?? instance.clerkId }; } if (isProPlan) { const inputTokens = estimateTokensFromText(args.question); - const now = Date.now(); - const sandboxUsageHours = args.resources.length - ? estimateSandboxUsageHours({ lastActiveAt: instance.lastActiveAt, now }) - : 0; - - const requiredTokensIn = inputTokens > 0 ? inputTokens : undefined; - const requiredTokensOut = 1; - const requiredSandboxHours = sandboxUsageHours > 0 ? sandboxUsageHours : undefined; - - const [tokensInResult, tokensOutResult, sandboxHoursResult] = await Promise.all([ - checkFeature({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.tokensIn, - requiredBalance: requiredTokensIn - }), - checkFeature({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.tokensOut, - requiredBalance: requiredTokensOut - }), - checkFeature({ + const requiredBudgetMicros = getPreflightAiBudgetMicros({ + modelId, + inputTokens + }); + const aiBudget = unwrapUsage( + await resolveProUsageMetrics({ customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.sandboxHours, - requiredBalance: requiredSandboxHours + requiredBalance: requiredBudgetMicros }) - ]); - const tokensIn = unwrapUsage(tokensInResult); - const tokensOut = unwrapUsage(tokensOutResult); - const sandboxHours = unwrapUsage(sandboxHoursResult); - - const hasEnough = (balance: number, required?: number) => - required == null ? balance > 0 : balance >= required; - - const ok = - hasEnough(tokensIn.balance, requiredTokensIn) && - hasEnough(tokensOut.balance, requiredTokensOut) && - hasEnough(sandboxHours.balance, requiredSandboxHours); + ); + const ok = aiBudget.balance >= requiredBudgetMicros; if (!ok) { - const limitTypes: string[] = []; - if (!hasEnough(tokensIn.balance, requiredTokensIn)) limitTypes.push('tokensIn'); - if (!hasEnough(tokensOut.balance, requiredTokensOut)) limitTypes.push('tokensOut'); - if (!hasEnough(sandboxHours.balance, requiredSandboxHours)) limitTypes.push('sandboxHours'); - await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, { distinctId: instance.clerkId, event: AnalyticsEvents.USAGE_LIMIT_REACHED, properties: { instanceId: args.instanceId, - limitTypes, - tokensInBalance: tokensIn.balance, - tokensOutBalance: tokensOut.balance, - sandboxHoursBalance: sandboxHours.balance + limitTypes: ['aiBudget'], + modelId, + requiredBudgetMicros, + aiBudgetBalance: aiBudget.balance } }); } @@ -694,12 +752,11 @@ export const ensureUsageAvailable = action({ ok, reason: ok ? null : 'limit_reached', metrics: { - tokensIn, - tokensOut, - sandboxHours + aiBudget }, inputTokens, - sandboxUsageHours, + requiredBudgetMicros, + modelId, customerId: autumnCustomer.id ?? instance.clerkId }; } @@ -714,15 +771,16 @@ export const ensureUsageAvailable = action({ export const finalizeUsage = action({ args: { instanceId: v.id('instances'), - questionTokens: v.number(), - outputChars: v.number(), - reasoningChars: v.number(), - resources: v.array(v.string()), - sandboxUsageHours: v.optional(v.number()) + modelId: v.string(), + inputTokens: v.number(), + outputTokens: v.number(), + reasoningTokens: v.optional(v.number()), + cacheReadTokens: v.optional(v.number()), + cacheWriteTokens: v.optional(v.number()), + chargedBudgetMicros: v.optional(v.number()) }, returns: v.object({ - outputTokens: v.number(), - sandboxUsageHours: v.number(), + chargedBudgetMicros: v.number(), customerId: v.string() }), handler: async (ctx, args): Promise => { @@ -759,38 +817,30 @@ export const finalizeUsage = action({ ); } - const outputTokens = isProPlan - ? estimateTokensFromChars(args.outputChars + args.reasoningChars) + const chargedBudgetMicros = isProPlan + ? Math.max( + 0, + args.chargedBudgetMicros ?? + totalAiBudgetMicros({ + modelId: args.modelId, + inputTokens: args.inputTokens, + outputTokens: args.outputTokens, + reasoningTokens: args.reasoningTokens, + cacheReadTokens: args.cacheReadTokens, + cacheWriteTokens: args.cacheWriteTokens + }) + ) : 0; - const sandboxUsageHours = isProPlan ? (args.sandboxUsageHours ?? 0) : 0; - if (isProPlan) { - if (args.questionTokens > 0) { - tasks.push( - trackUsage({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.tokensIn, - value: args.questionTokens - }) - ); - } - if (outputTokens > 0) { - tasks.push( - trackUsage({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.tokensOut, - value: outputTokens - }) - ); - } - if (sandboxUsageHours > 0) { - tasks.push( - trackUsage({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.sandboxHours, - value: sandboxUsageHours - }) - ); + if (isProPlan && chargedBudgetMicros > 0) { + const trackAiBudgetResult = await trackUsage({ + customerId: autumnCustomer.id ?? instance.clerkId, + featureId: FEATURE_IDS.aiBudget, + value: chargedBudgetMicros + }); + + if (Result.isError(trackAiBudgetResult)) { + throwUsageError(trackAiBudgetResult.error); } } @@ -802,8 +852,7 @@ export const finalizeUsage = action({ } return { - outputTokens, - sandboxUsageHours, + chargedBudgetMicros, customerId: autumnCustomer.id ?? instance.clerkId }; } @@ -827,12 +876,13 @@ export const getBillingSummary = action({ ), currentPeriodEnd: v.optional(v.number()), canceledAt: v.optional(v.number()), - customer: v.object({ name: v.null(), email: v.null() }), + customer: v.object({ + name: v.union(v.string(), v.null()), + email: v.union(v.string(), v.null()) + }), paymentMethod: v.any(), usage: v.object({ - tokensIn: usageMetricDisplayValidator, - tokensOut: usageMetricDisplayValidator, - sandboxHours: usageMetricDisplayValidator + aiBudget: usageMetricDisplayValidator }), freeMessages: v.optional( v.object({ @@ -876,39 +926,18 @@ export const getBillingSummary = action({ canceledAt: activeProduct?.canceled_at ?? undefined }); - const [tokensInResult, tokensOutResult, sandboxHoursResult, chatMessagesResult] = - await Promise.all([ - checkFeature({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.tokensIn - }), - checkFeature({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.tokensOut - }), - checkFeature({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.sandboxHours - }), - checkFeature({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.chatMessages - }) - ]); - const tokensIn = unwrapUsage(tokensInResult); - const tokensOut = unwrapUsage(tokensOutResult); - const sandboxHours = unwrapUsage(sandboxHoursResult); + const [proUsageResult, chatMessagesResult] = await Promise.all([ + resolveProUsageMetrics({ + customerId: autumnCustomer.id ?? instance.clerkId + }), + checkFeature({ + customerId: autumnCustomer.id ?? instance.clerkId, + featureId: FEATURE_IDS.chatMessages + }) + ]); + const aiBudget = unwrapUsage(proUsageResult); const chatMessages = unwrapUsage(chatMessagesResult); - - const toUsageMetric = (args: { usage: number; included: number; balance: number }) => { - const usedPct = args.included > 0 ? clampPercent((args.usage / args.included) * 100) : 0; - const remainingPct = clampPercent(100 - usedPct); - return { - usedPct, - remainingPct, - isDepleted: remainingPct <= 0 || args.balance <= 0 - }; - }; + const aiBudgetUsage = toUsageMetric(aiBudget); const result: BillingSummaryResult = { plan, @@ -916,14 +945,12 @@ export const getBillingSummary = action({ currentPeriodEnd: activeProduct?.current_period_end ?? undefined, canceledAt: activeProduct?.canceled_at ?? undefined, customer: { - name: null, - email: null + name: autumnCustomer.name ?? null, + email: autumnCustomer.email ?? null }, paymentMethod: autumnCustomer.payment_method ?? null, usage: { - tokensIn: toUsageMetric(tokensIn), - tokensOut: toUsageMetric(tokensOut), - sandboxHours: toUsageMetric(sandboxHours) + aiBudget: aiBudgetUsage } }; @@ -939,6 +966,59 @@ export const getBillingSummary = action({ } }); +export const resetProAiBudgetBalances = internalAction({ + args: {}, + returns: v.object({ + checked: v.number(), + reset: v.number(), + skipped: v.number(), + failed: v.number() + }), + handler: async (ctx) => { + const allInstances = await ctx.runQuery(scheduled.queries.listInstances, {}); + let reset = 0; + let skipped = 0; + let failed = 0; + + for (const instance of allInstances) { + try { + const autumnCustomer = unwrapUsage( + await getOrCreateCustomer({ + clerkId: instance.clerkId + }) + ); + const activeProduct = getActiveProduct(autumnCustomer.products); + if (activeProduct?.id !== 'btca_pro') { + skipped++; + continue; + } + + unwrapUsage( + await resetAiBudgetBalance({ + customerId: autumnCustomer.id ?? instance.clerkId, + remaining: PRO_AI_BUDGET_MICROS + }) + ); + reset++; + } catch (error) { + failed++; + console.error('Failed to reset AI budget balance', { + instanceId: instance._id, + clerkId: instance.clerkId, + error + }); + } + } + + return { + checked: allInstances.length, + reset, + skipped, + failed + }; + } +}); + export const createCheckoutSession = action({ args: { instanceId: v.id('instances'), @@ -975,7 +1055,11 @@ export const createCheckoutSession = action({ await createCheckoutSessionUrl({ autumnClient: unwrapUsage(getAutumnClientResult()), baseUrl: args.baseUrl, - customerId: autumnCustomer.id ?? instance.clerkId + customerId: autumnCustomer.id ?? instance.clerkId, + customerData: { + email: autumnCustomer.email, + name: autumnCustomer.name + } }) ); diff --git a/apps/web/src/lib/billing/aiBudget.ts b/apps/web/src/lib/billing/aiBudget.ts new file mode 100644 index 00000000..62bc10dc --- /dev/null +++ b/apps/web/src/lib/billing/aiBudget.ts @@ -0,0 +1,43 @@ +import { getWebSandboxModel } from '../models/webSandboxModels.ts'; + +export const USD_MICROS_PER_USD = 1_000_000; +export const PRO_AI_BUDGET_USD = 5; +export const PRO_AI_BUDGET_MICROS = PRO_AI_BUDGET_USD * USD_MICROS_PER_USD; + +const toUsdMicros = (usd?: number) => (usd == null ? 0 : Math.round(usd * USD_MICROS_PER_USD)); + +const costPartMicros = (tokens: number, usdPerMTokens?: number) => { + if (tokens <= 0 || usdPerMTokens == null) return 0; + return Math.max(1, Math.round((tokens * toUsdMicros(usdPerMTokens)) / 1_000_000)); +}; + +export const totalAiBudgetMicros = (args: { + modelId?: string | null; + inputTokens?: number; + outputTokens?: number; + reasoningTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; +}) => { + const model = getWebSandboxModel(args.modelId); + const outputTokens = (args.outputTokens ?? 0) + (args.reasoningTokens ?? 0); + const cacheWriteRate = + 'cacheWrite' in model.ratesUsdPerMTokens ? model.ratesUsdPerMTokens.cacheWrite : undefined; + + return ( + costPartMicros(args.inputTokens ?? 0, model.ratesUsdPerMTokens.input) + + costPartMicros(outputTokens, model.ratesUsdPerMTokens.output) + + costPartMicros(args.cacheReadTokens ?? 0, model.ratesUsdPerMTokens.cacheRead) + + costPartMicros(args.cacheWriteTokens ?? 0, cacheWriteRate) + ); +}; + +export const getPreflightAiBudgetMicros = (args: { + modelId?: string | null; + inputTokens?: number; +}) => + totalAiBudgetMicros({ + modelId: args.modelId, + inputTokens: args.inputTokens, + outputTokens: 1 + }); diff --git a/apps/web/src/lib/billing/plans.ts b/apps/web/src/lib/billing/plans.ts index cae327c8..f4c708d8 100644 --- a/apps/web/src/lib/billing/plans.ts +++ b/apps/web/src/lib/billing/plans.ts @@ -1,20 +1,24 @@ +import { PRO_AI_BUDGET_MICROS, PRO_AI_BUDGET_USD } from './aiBudget.ts'; +import { WEB_SANDBOX_MODELS } from '../models/webSandboxModels.ts'; + export const BILLING_PLAN = { id: 'btca_pro', name: 'Pro', priceUsd: 8, interval: 'month', - model: 'claude-haiku-4-5', - limits: { - tokensIn: 1_500_000, - tokensOut: 300_000, - sandboxHours: 6 - } + aiBudgetUsd: PRO_AI_BUDGET_USD, + aiBudgetMicros: PRO_AI_BUDGET_MICROS, + models: WEB_SANDBOX_MODELS.map(({ id, label, tier, description }) => ({ + id, + label, + tier, + description + })) } as const; export const FEATURE_IDS = { - tokensIn: 'tokens_in', - tokensOut: 'tokens_out', - sandboxHours: 'sandbox_hours' + aiBudget: 'ai_budget', + chatMessages: 'chat_messages' } as const; export const SUPPORT_URL = 'https://x.com/davis7'; diff --git a/apps/web/src/lib/billing/types.ts b/apps/web/src/lib/billing/types.ts index c3e40b24..b7d28354 100644 --- a/apps/web/src/lib/billing/types.ts +++ b/apps/web/src/lib/billing/types.ts @@ -23,9 +23,7 @@ export type BillingSummary = { }; } | null; usage: { - tokensIn: UsageMetric; - tokensOut: UsageMetric; - sandboxHours: UsageMetric; + aiBudget: UsageMetric; }; freeMessages?: { used: number; diff --git a/apps/web/src/lib/components/AddResourceModal.svelte b/apps/web/src/lib/components/AddResourceModal.svelte index a0567e7e..1ee85e7e 100644 --- a/apps/web/src/lib/components/AddResourceModal.svelte +++ b/apps/web/src/lib/components/AddResourceModal.svelte @@ -10,6 +10,7 @@ import { useConvexClient } from 'convex-svelte'; import { api } from '../../convex/_generated/api'; import { getAuthState } from '$lib/stores/auth.svelte'; + import { getProjectStore } from '$lib/stores/project.svelte'; interface Props { isOpen: boolean; @@ -29,6 +30,10 @@ const auth = getAuthState(); const client = useConvexClient(); + const projectStore = getProjectStore(); + + const selectedProject = $derived(projectStore.selectedProject); + const selectedProjectId = $derived(selectedProject?._id); let resourceType = $state('git'); let gitUrl = $state(''); @@ -203,7 +208,8 @@ url: resourceType === 'git' ? parsed?.url : undefined, branch: resourceType === 'git' ? branchName.trim() || 'main' : undefined, package: resourceType === 'npm' ? packageName.trim() : undefined, - version: resourceType === 'npm' ? packageVersion.trim() || undefined : undefined + version: resourceType === 'npm' ? packageVersion.trim() || undefined : undefined, + projectId: selectedProjectId }); closeModal(); } catch (error) { @@ -413,7 +419,14 @@

- We'll sync this resource onto your instance after the next chat mention. + {#if selectedProject} + This resource will be added to {selectedProject.name} + and synced onto your instance after the next chat mention. + {:else} + We'll sync this resource onto your instance after the next chat mention. + {/if}

@@ -403,58 +403,35 @@
-
-
-

Versions

-
-
- btca -
- {instanceStore.btcaVersion ?? 'Unknown'} - {#if instanceStore.btcaUpdateAvailable} - - → {instanceStore.latestBtcaVersion} - - {/if} -
-
-
- opencode -
- {instanceStore.opencodeVersion ?? 'Unknown'} - {#if instanceStore.opencodeUpdateAvailable} - - → {instanceStore.latestOpencodeVersion} - - {/if} -
+
+

Versions

+
+
+ btca +
+ {instanceStore.btcaVersion ?? 'Unknown'} + {#if instanceStore.btcaUpdateAvailable} + + → {instanceStore.latestBtcaVersion} + + {/if}
-

- Last check: {formatDateTime(instanceStore.instance?.lastVersionCheck)} -

-
- -
-

Storage

-
- Used - - {storageUsed - ? `${formatBytes(storageUsed)} of ${formatBytes(storageLimitBytes)}` - : 'Usage pending'} - -
-
-
+
+ opencode +
+ {instanceStore.opencodeVersion ?? 'Unknown'} + {#if instanceStore.opencodeUpdateAvailable} + + → {instanceStore.latestOpencodeVersion} + + {/if} +
-

Storage updates after provisioning completes.

+

+ Last check: {formatDateTime(instanceStore.instance?.lastVersionCheck)} +

diff --git a/apps/web/src/lib/components/InstanceStatus.svelte b/apps/web/src/lib/components/InstanceStatus.svelte index 6de47c66..01c7b42c 100644 --- a/apps/web/src/lib/components/InstanceStatus.svelte +++ b/apps/web/src/lib/components/InstanceStatus.svelte @@ -10,6 +10,7 @@ Square } from '@lucide/svelte'; import { getInstanceStore } from '$lib/stores/instance.svelte'; + import { INSTANCE_DISK_FULL_MESSAGE } from '$lib/instanceErrors'; type InstanceAction = 'wake' | 'stop' | 'update' | 'reset'; @@ -83,6 +84,14 @@ const stateInfo = $derived.by(() => { const state = instanceStore.state ?? 'unknown'; + if (state === 'error' && instanceStore.errorKind === 'disk_full') { + return { + label: 'Cache full', + description: 'Your instance cache is full. Reset it to continue.', + tone: 'error' as const, + icon: AlertTriangle + }; + } return ( stateMeta[state] ?? { label: 'Checking', @@ -120,6 +129,7 @@ const canStop = $derived.by(() => instanceStore.state === 'running'); const canUpdate = $derived.by(() => ['running', 'stopped'].includes(instanceStore.state ?? '')); const canReset = $derived.by(() => instanceStore.state === 'error'); + const isDiskFullError = $derived.by(() => instanceStore.errorKind === 'disk_full'); const updateSummary = $derived.by(() => { const parts: string[] = []; @@ -245,7 +255,11 @@
Instance needs attention

- Try resetting your instance. This will fully restart it from scratch. + {#if isDiskFullError} + {INSTANCE_DISK_FULL_MESSAGE} + {:else} + Try resetting your instance. This will fully restart it from scratch. + {/if}

{/if} {/if} diff --git a/apps/web/src/lib/components/ProjectModelPicker.svelte b/apps/web/src/lib/components/ProjectModelPicker.svelte new file mode 100644 index 00000000..0cfb9d86 --- /dev/null +++ b/apps/web/src/lib/components/ProjectModelPicker.svelte @@ -0,0 +1,194 @@ + + +{#if selectedProject} +
+ + + {#if isOpen} +
+ {#each WEB_SANDBOX_MODELS as model} + + {/each} +
+ {/if} +
+{/if} + + diff --git a/apps/web/src/lib/components/ProvisioningModal.svelte b/apps/web/src/lib/components/ProvisioningModal.svelte index 47df7080..d91f0aef 100644 --- a/apps/web/src/lib/components/ProvisioningModal.svelte +++ b/apps/web/src/lib/components/ProvisioningModal.svelte @@ -14,11 +14,17 @@ const isBootstrapping = $derived(instanceStore.isBootstrapping); const hasInstance = $derived(Boolean(instanceStore.instance)); const instanceState = $derived(instanceStore.state ?? ''); + const hasProvisionedAt = $derived(Boolean(instanceStore.instance?.provisionedAt)); + const ensureStatus = $derived(instanceStore.ensureStatus); + const isEnsureProvisioning = $derived( + ensureStatus === 'created' || ensureStatus === 'provisioning' + ); const isVisible = $derived.by(() => { if (instanceStore.isLoading) return false; - if (isBootstrapping) return true; + if (isBootstrapping && isEnsureProvisioning) return true; if (!hasInstance) return false; + if (hasProvisionedAt) return false; return instanceState === 'unprovisioned' || instanceState === 'provisioning'; }); @@ -36,7 +42,6 @@ const hasInstanceRecord = $derived(hasInstance); const hasSandbox = $derived(Boolean(instanceStore.instance?.sandboxId)); - const hasProvisionedAt = $derived(Boolean(instanceStore.instance?.provisionedAt)); const hasPackages = $derived(Boolean(instanceStore.btcaVersion || instanceStore.opencodeVersion)); const hasServer = $derived(Boolean(instanceStore.instance?.serverUrl)); diff --git a/apps/web/src/lib/components/pricing/PricingPlans.svelte b/apps/web/src/lib/components/pricing/PricingPlans.svelte index b5a37a5d..1d819d23 100644 --- a/apps/web/src/lib/components/pricing/PricingPlans.svelte +++ b/apps/web/src/lib/components/pricing/PricingPlans.svelte @@ -1,6 +1,6 @@