init claude-code
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getOauthConfig } from 'src/constants/oauth.js'
|
||||
import { getOrganizationUUID } from 'src/services/oauth/client.js'
|
||||
import z from 'zod/v4'
|
||||
import { getClaudeAIOAuthTokens } from '../auth.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { parseGitHubRepository } from '../detectRepository.js'
|
||||
import { errorMessage, toError } from '../errors.js'
|
||||
import { lazySchema } from '../lazySchema.js'
|
||||
import { logError } from '../log.js'
|
||||
import { sleep } from '../sleep.js'
|
||||
import { jsonStringify } from '../slowOperations.js'
|
||||
|
||||
// Retry configuration for teleport API requests
|
||||
const TELEPORT_RETRY_DELAYS = [2000, 4000, 8000, 16000] // 4 retries with exponential backoff
|
||||
const MAX_TELEPORT_RETRIES = TELEPORT_RETRY_DELAYS.length
|
||||
|
||||
export const CCR_BYOC_BETA = 'ccr-byoc-2025-07-29'
|
||||
|
||||
/**
|
||||
* Checks if an axios error is a transient network error that should be retried
|
||||
*/
|
||||
export function isTransientNetworkError(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Retry on network errors (no response received)
|
||||
if (!error.response) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Retry on server errors (5xx)
|
||||
if (error.response.status >= 500) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Don't retry on client errors (4xx) - they're not transient
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an axios GET request with automatic retry for transient network errors
|
||||
* Uses exponential backoff: 2s, 4s, 8s, 16s (4 retries = 5 total attempts)
|
||||
*/
|
||||
export async function axiosGetWithRetry<T>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_TELEPORT_RETRIES; attempt++) {
|
||||
try {
|
||||
return await axios.get<T>(url, config)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
// Don't retry if this isn't a transient error
|
||||
if (!isTransientNetworkError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Don't retry if we've exhausted all retries
|
||||
if (attempt >= MAX_TELEPORT_RETRIES) {
|
||||
logForDebugging(
|
||||
`Teleport request failed after ${attempt + 1} attempts: ${errorMessage(error)}`,
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
const delay = TELEPORT_RETRY_DELAYS[attempt] ?? 2000
|
||||
logForDebugging(
|
||||
`Teleport request failed (attempt ${attempt + 1}/${MAX_TELEPORT_RETRIES + 1}), retrying in ${delay}ms: ${errorMessage(error)}`,
|
||||
)
|
||||
await sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// Types matching the actual Sessions API response from api/schemas/sessions/sessions.py
|
||||
export type SessionStatus = 'requires_action' | 'running' | 'idle' | 'archived'
|
||||
|
||||
export type GitSource = {
|
||||
type: 'git_repository'
|
||||
url: string
|
||||
revision?: string | null
|
||||
allow_unrestricted_git_push?: boolean
|
||||
}
|
||||
|
||||
export type KnowledgeBaseSource = {
|
||||
type: 'knowledge_base'
|
||||
knowledge_base_id: string
|
||||
}
|
||||
|
||||
export type SessionContextSource = GitSource | KnowledgeBaseSource
|
||||
|
||||
// Outcome types from api/schemas/sandbox.py
|
||||
export type OutcomeGitInfo = {
|
||||
type: 'github'
|
||||
repo: string
|
||||
branches: string[]
|
||||
}
|
||||
|
||||
export type GitRepositoryOutcome = {
|
||||
type: 'git_repository'
|
||||
git_info: OutcomeGitInfo
|
||||
}
|
||||
|
||||
export type Outcome = GitRepositoryOutcome
|
||||
|
||||
export type SessionContext = {
|
||||
sources: SessionContextSource[]
|
||||
cwd: string
|
||||
outcomes: Outcome[] | null
|
||||
custom_system_prompt: string | null
|
||||
append_system_prompt: string | null
|
||||
model: string | null
|
||||
// Seed filesystem with a git bundle on Files API
|
||||
seed_bundle_file_id?: string
|
||||
github_pr?: { owner: string; repo: string; number: number }
|
||||
reuse_outcome_branches?: boolean
|
||||
}
|
||||
|
||||
export type SessionResource = {
|
||||
type: 'session'
|
||||
id: string
|
||||
title: string | null
|
||||
session_status: SessionStatus
|
||||
environment_id: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
session_context: SessionContext
|
||||
}
|
||||
|
||||
export type ListSessionsResponse = {
|
||||
data: SessionResource[]
|
||||
has_more: boolean
|
||||
first_id: string | null
|
||||
last_id: string | null
|
||||
}
|
||||
|
||||
export const CodeSessionSchema = lazySchema(() =>
|
||||
z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
status: z.enum([
|
||||
'idle',
|
||||
'working',
|
||||
'waiting',
|
||||
'completed',
|
||||
'archived',
|
||||
'cancelled',
|
||||
'rejected',
|
||||
]),
|
||||
repo: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
owner: z.object({
|
||||
login: z.string(),
|
||||
}),
|
||||
default_branch: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
turns: z.array(z.string()),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
// Export the inferred type from the Zod schema
|
||||
export type CodeSession = z.infer<ReturnType<typeof CodeSessionSchema>>
|
||||
|
||||
/**
|
||||
* Validates and prepares for API requests
|
||||
* @returns Object containing access token and organization UUID
|
||||
*/
|
||||
export async function prepareApiRequest(): Promise<{
|
||||
accessToken: string
|
||||
orgUUID: string
|
||||
}> {
|
||||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||||
if (accessToken === undefined) {
|
||||
throw new Error(
|
||||
'Claude Code web sessions require authentication with a Claude.ai account. API key authentication is not sufficient. Please run /login to authenticate, or check your authentication status with /status.',
|
||||
)
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
throw new Error('Unable to get organization UUID')
|
||||
}
|
||||
|
||||
return { accessToken, orgUUID }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches code sessions from the new Sessions API (/v1/sessions)
|
||||
* @returns Array of code sessions
|
||||
*/
|
||||
export async function fetchCodeSessionsFromSessionsAPI(): Promise<
|
||||
CodeSession[]
|
||||
> {
|
||||
const { accessToken, orgUUID } = await prepareApiRequest()
|
||||
|
||||
const url = `${getOauthConfig().BASE_API_URL}/v1/sessions`
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const response = await axiosGetWithRetry<ListSessionsResponse>(url, {
|
||||
headers,
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to fetch code sessions: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Transform SessionResource[] to CodeSession[] format
|
||||
const sessions: CodeSession[] = response.data.data.map(session => {
|
||||
// Extract repository info from git sources
|
||||
const gitSource = session.session_context.sources.find(
|
||||
(source): source is GitSource => source.type === 'git_repository',
|
||||
)
|
||||
|
||||
let repo: CodeSession['repo'] = null
|
||||
if (gitSource?.url) {
|
||||
// Parse GitHub URL using the existing utility function
|
||||
const repoPath = parseGitHubRepository(gitSource.url)
|
||||
if (repoPath) {
|
||||
const [owner, name] = repoPath.split('/')
|
||||
if (owner && name) {
|
||||
repo = {
|
||||
name,
|
||||
owner: {
|
||||
login: owner,
|
||||
},
|
||||
default_branch: gitSource.revision || undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
title: session.title || 'Untitled',
|
||||
description: '', // SessionResource doesn't have description field
|
||||
status: session.session_status as CodeSession['status'], // Map session_status to status
|
||||
repo,
|
||||
turns: [], // SessionResource doesn't have turns field
|
||||
created_at: session.created_at,
|
||||
updated_at: session.updated_at,
|
||||
}
|
||||
})
|
||||
|
||||
return sessions
|
||||
} catch (error) {
|
||||
const err = toError(error)
|
||||
logError(err)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates OAuth headers for API requests
|
||||
* @param accessToken The OAuth access token
|
||||
* @returns Headers object with Authorization, Content-Type, and anthropic-version
|
||||
*/
|
||||
export function getOAuthHeaders(accessToken: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single session by ID from the Sessions API
|
||||
* @param sessionId The session ID to fetch
|
||||
* @returns The session resource
|
||||
*/
|
||||
export async function fetchSession(
|
||||
sessionId: string,
|
||||
): Promise<SessionResource> {
|
||||
const { accessToken, orgUUID } = await prepareApiRequest()
|
||||
|
||||
const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const response = await axios.get<SessionResource>(url, {
|
||||
headers,
|
||||
timeout: 15000,
|
||||
validateStatus: status => status < 500,
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
// Extract error message from response if available
|
||||
const errorData = response.data as { error?: { message?: string } }
|
||||
const apiMessage = errorData?.error?.message
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error('Session expired. Please run /login to sign in again.')
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
apiMessage ||
|
||||
`Failed to fetch session: ${response.status} ${response.statusText}`,
|
||||
)
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the first branch name from a session's git repository outcomes
|
||||
* @param session The session resource to extract from
|
||||
* @returns The first branch name, or undefined if none found
|
||||
*/
|
||||
export function getBranchFromSession(
|
||||
session: SessionResource,
|
||||
): string | undefined {
|
||||
const gitOutcome = session.session_context.outcomes?.find(
|
||||
(outcome): outcome is GitRepositoryOutcome =>
|
||||
outcome.type === 'git_repository',
|
||||
)
|
||||
return gitOutcome?.git_info?.branches[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Content for a remote session message.
|
||||
* Accepts a plain string or an array of content blocks (text, image, etc.)
|
||||
* following the Anthropic API messages spec.
|
||||
*/
|
||||
export type RemoteMessageContent =
|
||||
| string
|
||||
| Array<{ type: string; [key: string]: unknown }>
|
||||
|
||||
/**
|
||||
* Sends a user message event to an existing remote session via the Sessions API
|
||||
* @param sessionId The session ID to send the event to
|
||||
* @param messageContent The user message content (string or content blocks)
|
||||
* @param opts.uuid Optional UUID for the event — callers that added a local
|
||||
* UserMessage first should pass its UUID so echo filtering can dedup
|
||||
* @returns Promise<boolean> True if successful, false otherwise
|
||||
*/
|
||||
export async function sendEventToRemoteSession(
|
||||
sessionId: string,
|
||||
messageContent: RemoteMessageContent,
|
||||
opts?: { uuid?: string },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { accessToken, orgUUID } = await prepareApiRequest()
|
||||
|
||||
const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events`
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const userEvent = {
|
||||
uuid: opts?.uuid ?? randomUUID(),
|
||||
session_id: sessionId,
|
||||
type: 'user',
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: messageContent,
|
||||
},
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
events: [userEvent],
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[sendEventToRemoteSession] Sending event to session ${sessionId}`,
|
||||
)
|
||||
// The endpoint may block until the CCR worker is ready. Observed ~2.6s
|
||||
// in normal cases; allow a generous margin for cold-start containers.
|
||||
const response = await axios.post(url, requestBody, {
|
||||
headers,
|
||||
validateStatus: status => status < 500,
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
logForDebugging(
|
||||
`[sendEventToRemoteSession] Successfully sent event to session ${sessionId}`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[sendEventToRemoteSession] Failed with status ${response.status}: ${jsonStringify(response.data)}`,
|
||||
)
|
||||
return false
|
||||
} catch (error) {
|
||||
logForDebugging(`[sendEventToRemoteSession] Error: ${errorMessage(error)}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the title of an existing remote session via the Sessions API
|
||||
* @param sessionId The session ID to update
|
||||
* @param title The new title for the session
|
||||
* @returns Promise<boolean> True if successful, false otherwise
|
||||
*/
|
||||
export async function updateSessionTitle(
|
||||
sessionId: string,
|
||||
title: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { accessToken, orgUUID } = await prepareApiRequest()
|
||||
|
||||
const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[updateSessionTitle] Updating title for session ${sessionId}: "${title}"`,
|
||||
)
|
||||
const response = await axios.patch(
|
||||
url,
|
||||
{ title },
|
||||
{
|
||||
headers,
|
||||
validateStatus: status => status < 500,
|
||||
},
|
||||
)
|
||||
|
||||
if (response.status === 200) {
|
||||
logForDebugging(
|
||||
`[updateSessionTitle] Successfully updated title for session ${sessionId}`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[updateSessionTitle] Failed with status ${response.status}: ${jsonStringify(response.data)}`,
|
||||
)
|
||||
return false
|
||||
} catch (error) {
|
||||
logForDebugging(`[updateSessionTitle] Error: ${errorMessage(error)}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { SETTING_SOURCES, type SettingSource } from '../settings/constants.js'
|
||||
import {
|
||||
getSettings_DEPRECATED,
|
||||
getSettingsForSource,
|
||||
} from '../settings/settings.js'
|
||||
import { type EnvironmentResource, fetchEnvironments } from './environments.js'
|
||||
|
||||
export type EnvironmentSelectionInfo = {
|
||||
availableEnvironments: EnvironmentResource[]
|
||||
selectedEnvironment: EnvironmentResource | null
|
||||
selectedEnvironmentSource: SettingSource | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets information about available environments and the currently selected one.
|
||||
*
|
||||
* @returns Promise<EnvironmentSelectionInfo> containing:
|
||||
* - availableEnvironments: all environments from the API
|
||||
* - selectedEnvironment: the environment that would be used (based on settings or first available),
|
||||
* or null if no environments are available
|
||||
* - selectedEnvironmentSource: the SettingSource where defaultEnvironmentId is configured,
|
||||
* or null if using the default (first environment)
|
||||
*/
|
||||
export async function getEnvironmentSelectionInfo(): Promise<EnvironmentSelectionInfo> {
|
||||
// Fetch available environments
|
||||
const environments = await fetchEnvironments()
|
||||
|
||||
if (environments.length === 0) {
|
||||
return {
|
||||
availableEnvironments: [],
|
||||
selectedEnvironment: null,
|
||||
selectedEnvironmentSource: null,
|
||||
}
|
||||
}
|
||||
|
||||
// Get the merged settings to see what would actually be used
|
||||
const mergedSettings = getSettings_DEPRECATED()
|
||||
const defaultEnvironmentId = mergedSettings?.remote?.defaultEnvironmentId
|
||||
|
||||
// Find which environment would be selected
|
||||
let selectedEnvironment: EnvironmentResource =
|
||||
environments.find(env => env.kind !== 'bridge') ?? environments[0]!
|
||||
let selectedEnvironmentSource: SettingSource | null = null
|
||||
|
||||
if (defaultEnvironmentId) {
|
||||
const matchingEnvironment = environments.find(
|
||||
env => env.environment_id === defaultEnvironmentId,
|
||||
)
|
||||
|
||||
if (matchingEnvironment) {
|
||||
selectedEnvironment = matchingEnvironment
|
||||
|
||||
// Find which source has this setting
|
||||
// Iterate from lowest to highest priority, so the last match wins (highest priority)
|
||||
for (let i = SETTING_SOURCES.length - 1; i >= 0; i--) {
|
||||
const source = SETTING_SOURCES[i]
|
||||
if (!source || source === 'flagSettings') {
|
||||
// Skip flagSettings as it's not a normal source we check
|
||||
continue
|
||||
}
|
||||
const sourceSettings = getSettingsForSource(source)
|
||||
if (
|
||||
sourceSettings?.remote?.defaultEnvironmentId === defaultEnvironmentId
|
||||
) {
|
||||
selectedEnvironmentSource = source
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
availableEnvironments: environments,
|
||||
selectedEnvironment,
|
||||
selectedEnvironmentSource,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import axios from 'axios'
|
||||
import { getOauthConfig } from 'src/constants/oauth.js'
|
||||
import { getOrganizationUUID } from 'src/services/oauth/client.js'
|
||||
import { getClaudeAIOAuthTokens } from '../auth.js'
|
||||
import { toError } from '../errors.js'
|
||||
import { logError } from '../log.js'
|
||||
import { getOAuthHeaders } from './api.js'
|
||||
|
||||
export type EnvironmentKind = 'anthropic_cloud' | 'byoc' | 'bridge'
|
||||
export type EnvironmentState = 'active'
|
||||
|
||||
export type EnvironmentResource = {
|
||||
kind: EnvironmentKind
|
||||
environment_id: string
|
||||
name: string
|
||||
created_at: string
|
||||
state: EnvironmentState
|
||||
}
|
||||
|
||||
export type EnvironmentListResponse = {
|
||||
environments: EnvironmentResource[]
|
||||
has_more: boolean
|
||||
first_id: string | null
|
||||
last_id: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the list of available environments from the Environment API
|
||||
* @returns Promise<EnvironmentResource[]> Array of available environments
|
||||
* @throws Error if the API request fails or no access token is available
|
||||
*/
|
||||
export async function fetchEnvironments(): Promise<EnvironmentResource[]> {
|
||||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
'Claude Code web sessions require authentication with a Claude.ai account. API key authentication is not sufficient. Please run /login to authenticate, or check your authentication status with /status.',
|
||||
)
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
throw new Error('Unable to get organization UUID')
|
||||
}
|
||||
|
||||
const url = `${getOauthConfig().BASE_API_URL}/v1/environment_providers`
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const response = await axios.get<EnvironmentListResponse>(url, {
|
||||
headers,
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
`Failed to fetch environments: ${response.status} ${response.statusText}`,
|
||||
)
|
||||
}
|
||||
|
||||
return response.data.environments
|
||||
} catch (error) {
|
||||
const err = toError(error)
|
||||
logError(err)
|
||||
throw new Error(`Failed to fetch environments: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a default anthropic_cloud environment for users who have none.
|
||||
* Uses the public environment_providers route (same auth as fetchEnvironments).
|
||||
*/
|
||||
export async function createDefaultCloudEnvironment(
|
||||
name: string,
|
||||
): Promise<EnvironmentResource> {
|
||||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error('No access token available')
|
||||
}
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
throw new Error('Unable to get organization UUID')
|
||||
}
|
||||
|
||||
const url = `${getOauthConfig().BASE_API_URL}/v1/environment_providers/cloud/create`
|
||||
const response = await axios.post<EnvironmentResource>(
|
||||
url,
|
||||
{
|
||||
name,
|
||||
kind: 'anthropic_cloud',
|
||||
description: '',
|
||||
config: {
|
||||
environment_type: 'anthropic',
|
||||
cwd: '/home/user',
|
||||
init_script: null,
|
||||
environment: {},
|
||||
languages: [
|
||||
{ name: 'python', version: '3.11' },
|
||||
{ name: 'node', version: '20' },
|
||||
],
|
||||
network_config: {
|
||||
allowed_hosts: [],
|
||||
allow_default_hosts: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
},
|
||||
timeout: 15000,
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Git bundle creation + upload for CCR seed-bundle seeding.
|
||||
*
|
||||
* Flow:
|
||||
* 1. git stash create → update-ref refs/seed/stash (makes it reachable)
|
||||
* 2. git bundle create --all (packs refs/seed/stash + its objects)
|
||||
* 3. Upload to /v1/files
|
||||
* 4. Cleanup refs/seed/stash (don't pollute user's repo)
|
||||
* 5. Caller sets seed_bundle_file_id on SessionContext
|
||||
*/
|
||||
|
||||
import { stat, unlink } from 'fs/promises'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import { type FilesApiConfig, uploadFile } from '../../services/api/filesApi.js'
|
||||
import { getCwd } from '../cwd.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
|
||||
import { findGitRoot, gitExe } from '../git.js'
|
||||
import { generateTempFilePath } from '../tempfile.js'
|
||||
|
||||
// Tunable via tengu_ccr_bundle_max_bytes.
|
||||
const DEFAULT_BUNDLE_MAX_BYTES = 100 * 1024 * 1024
|
||||
|
||||
type BundleScope = 'all' | 'head' | 'squashed'
|
||||
|
||||
export type BundleUploadResult =
|
||||
| {
|
||||
success: true
|
||||
fileId: string
|
||||
bundleSizeBytes: number
|
||||
scope: BundleScope
|
||||
hasWip: boolean
|
||||
}
|
||||
| { success: false; error: string; failReason?: BundleFailReason }
|
||||
|
||||
type BundleFailReason = 'git_error' | 'too_large' | 'empty_repo'
|
||||
|
||||
type BundleCreateResult =
|
||||
| { ok: true; size: number; scope: BundleScope }
|
||||
| { ok: false; error: string; failReason: BundleFailReason }
|
||||
|
||||
// Bundle --all → HEAD → squashed-root. HEAD drops side branches/tags but
|
||||
// keeps full current-branch history. Squashed-root is a single parentless
|
||||
// commit of HEAD's tree (or the stash tree if WIP exists) — no history,
|
||||
// just the snapshot. Receiver needs refs/seed/root handling for that tier.
|
||||
async function _bundleWithFallback(
|
||||
gitRoot: string,
|
||||
bundlePath: string,
|
||||
maxBytes: number,
|
||||
hasStash: boolean,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<BundleCreateResult> {
|
||||
// --all picks up refs/seed/stash; HEAD needs it explicit.
|
||||
const extra = hasStash ? ['refs/seed/stash'] : []
|
||||
const mkBundle = (base: string) =>
|
||||
execFileNoThrowWithCwd(
|
||||
gitExe(),
|
||||
['bundle', 'create', bundlePath, base, ...extra],
|
||||
{ cwd: gitRoot, abortSignal: signal },
|
||||
)
|
||||
|
||||
const allResult = await mkBundle('--all')
|
||||
if (allResult.code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `git bundle create --all failed (${allResult.code}): ${allResult.stderr.slice(0, 200)}`,
|
||||
failReason: 'git_error',
|
||||
}
|
||||
}
|
||||
|
||||
const { size: allSize } = await stat(bundlePath)
|
||||
if (allSize <= maxBytes) {
|
||||
return { ok: true, size: allSize, scope: 'all' }
|
||||
}
|
||||
|
||||
// bundle create overwrites in place.
|
||||
logForDebugging(
|
||||
`[gitBundle] --all bundle is ${(allSize / 1024 / 1024).toFixed(1)}MB (> ${(maxBytes / 1024 / 1024).toFixed(0)}MB), retrying HEAD-only`,
|
||||
)
|
||||
const headResult = await mkBundle('HEAD')
|
||||
if (headResult.code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `git bundle create HEAD failed (${headResult.code}): ${headResult.stderr.slice(0, 200)}`,
|
||||
failReason: 'git_error',
|
||||
}
|
||||
}
|
||||
|
||||
const { size: headSize } = await stat(bundlePath)
|
||||
if (headSize <= maxBytes) {
|
||||
return { ok: true, size: headSize, scope: 'head' }
|
||||
}
|
||||
|
||||
// Last resort: squash to a single parentless commit. Uses the stash tree
|
||||
// when WIP exists (bakes uncommitted changes in — can't bundle the stash
|
||||
// ref separately since its parents would drag history back).
|
||||
logForDebugging(
|
||||
`[gitBundle] HEAD bundle is ${(headSize / 1024 / 1024).toFixed(1)}MB, retrying squashed-root`,
|
||||
)
|
||||
const treeRef = hasStash ? 'refs/seed/stash^{tree}' : 'HEAD^{tree}'
|
||||
const commitTree = await execFileNoThrowWithCwd(
|
||||
gitExe(),
|
||||
['commit-tree', treeRef, '-m', 'seed'],
|
||||
{ cwd: gitRoot, abortSignal: signal },
|
||||
)
|
||||
if (commitTree.code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `git commit-tree failed (${commitTree.code}): ${commitTree.stderr.slice(0, 200)}`,
|
||||
failReason: 'git_error',
|
||||
}
|
||||
}
|
||||
const squashedSha = commitTree.stdout.trim()
|
||||
await execFileNoThrowWithCwd(
|
||||
gitExe(),
|
||||
['update-ref', 'refs/seed/root', squashedSha],
|
||||
{ cwd: gitRoot },
|
||||
)
|
||||
const squashResult = await execFileNoThrowWithCwd(
|
||||
gitExe(),
|
||||
['bundle', 'create', bundlePath, 'refs/seed/root'],
|
||||
{ cwd: gitRoot, abortSignal: signal },
|
||||
)
|
||||
if (squashResult.code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `git bundle create refs/seed/root failed (${squashResult.code}): ${squashResult.stderr.slice(0, 200)}`,
|
||||
failReason: 'git_error',
|
||||
}
|
||||
}
|
||||
const { size: squashSize } = await stat(bundlePath)
|
||||
if (squashSize <= maxBytes) {
|
||||
return { ok: true, size: squashSize, scope: 'squashed' }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'Repo is too large to bundle. Please setup GitHub on https://claude.ai/code',
|
||||
failReason: 'too_large',
|
||||
}
|
||||
}
|
||||
|
||||
// Bundle the repo and upload to Files API; return file_id for
|
||||
// seed_bundle_file_id. --all → HEAD → squashed-root fallback chain.
|
||||
// Tracked WIP via stash create → refs/seed/stash (or baked into the
|
||||
// squashed tree); untracked not captured.
|
||||
export async function createAndUploadGitBundle(
|
||||
config: FilesApiConfig,
|
||||
opts?: { cwd?: string; signal?: AbortSignal },
|
||||
): Promise<BundleUploadResult> {
|
||||
const workdir = opts?.cwd ?? getCwd()
|
||||
const gitRoot = findGitRoot(workdir)
|
||||
if (!gitRoot) {
|
||||
return { success: false, error: 'Not in a git repository' }
|
||||
}
|
||||
|
||||
// Sweep stale refs from a crashed prior run before --all bundles them.
|
||||
// Runs before the empty-repo check so it's never skipped by an early return.
|
||||
for (const ref of ['refs/seed/stash', 'refs/seed/root']) {
|
||||
await execFileNoThrowWithCwd(gitExe(), ['update-ref', '-d', ref], {
|
||||
cwd: gitRoot,
|
||||
})
|
||||
}
|
||||
|
||||
// `git bundle create` refuses to create an empty bundle (exit 128), and
|
||||
// `stash create` fails with "You do not have the initial commit yet".
|
||||
// Check for any refs (not just HEAD) so orphan branches with commits
|
||||
// elsewhere still bundle — `--all` packs those refs regardless of HEAD.
|
||||
const refCheck = await execFileNoThrowWithCwd(
|
||||
gitExe(),
|
||||
['for-each-ref', '--count=1', 'refs/'],
|
||||
{ cwd: gitRoot },
|
||||
)
|
||||
if (refCheck.code === 0 && refCheck.stdout.trim() === '') {
|
||||
logEvent('tengu_ccr_bundle_upload', {
|
||||
outcome:
|
||||
'empty_repo' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: 'Repository has no commits yet',
|
||||
failReason: 'empty_repo',
|
||||
}
|
||||
}
|
||||
|
||||
// stash create writes a dangling commit — doesn't touch refs/stash or
|
||||
// the working tree. Untracked files intentionally excluded.
|
||||
const stashResult = await execFileNoThrowWithCwd(
|
||||
gitExe(),
|
||||
['stash', 'create'],
|
||||
{ cwd: gitRoot, abortSignal: opts?.signal },
|
||||
)
|
||||
// exit 0 + empty stdout = nothing to stash. Nonzero is rare; non-fatal.
|
||||
const wipStashSha = stashResult.code === 0 ? stashResult.stdout.trim() : ''
|
||||
const hasWip = wipStashSha !== ''
|
||||
if (stashResult.code !== 0) {
|
||||
logForDebugging(
|
||||
`[gitBundle] git stash create failed (${stashResult.code}), proceeding without WIP: ${stashResult.stderr.slice(0, 200)}`,
|
||||
)
|
||||
} else if (hasWip) {
|
||||
logForDebugging(`[gitBundle] Captured WIP as stash ${wipStashSha}`)
|
||||
// env-runner reads the SHA via bundle list-heads refs/seed/stash.
|
||||
await execFileNoThrowWithCwd(
|
||||
gitExe(),
|
||||
['update-ref', 'refs/seed/stash', wipStashSha],
|
||||
{ cwd: gitRoot },
|
||||
)
|
||||
}
|
||||
|
||||
const bundlePath = generateTempFilePath('ccr-seed', '.bundle')
|
||||
|
||||
// git leaves a partial file on nonzero exit (e.g. empty-repo 128).
|
||||
try {
|
||||
const maxBytes =
|
||||
getFeatureValue_CACHED_MAY_BE_STALE<number | null>(
|
||||
'tengu_ccr_bundle_max_bytes',
|
||||
null,
|
||||
) ?? DEFAULT_BUNDLE_MAX_BYTES
|
||||
|
||||
const bundle = await _bundleWithFallback(
|
||||
gitRoot,
|
||||
bundlePath,
|
||||
maxBytes,
|
||||
hasWip,
|
||||
opts?.signal,
|
||||
)
|
||||
|
||||
if (!bundle.ok) {
|
||||
logForDebugging(`[gitBundle] ${bundle.error}`)
|
||||
logEvent('tengu_ccr_bundle_upload', {
|
||||
outcome:
|
||||
bundle.failReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
max_bytes: maxBytes,
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: bundle.error,
|
||||
failReason: bundle.failReason,
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed relativePath so CCR can locate it.
|
||||
const upload = await uploadFile(bundlePath, '_source_seed.bundle', config, {
|
||||
signal: opts?.signal,
|
||||
})
|
||||
|
||||
if (!upload.success) {
|
||||
logEvent('tengu_ccr_bundle_upload', {
|
||||
outcome:
|
||||
'failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
return { success: false, error: upload.error }
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[gitBundle] Uploaded ${upload.size} bytes as file_id ${upload.fileId}`,
|
||||
)
|
||||
logEvent('tengu_ccr_bundle_upload', {
|
||||
outcome:
|
||||
'success' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
size_bytes: upload.size,
|
||||
scope:
|
||||
bundle.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
has_wip: hasWip,
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
fileId: upload.fileId,
|
||||
bundleSizeBytes: upload.size,
|
||||
scope: bundle.scope,
|
||||
hasWip,
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await unlink(bundlePath)
|
||||
} catch {
|
||||
logForDebugging(`[gitBundle] Could not delete ${bundlePath} (non-fatal)`)
|
||||
}
|
||||
// Always delete — also sweeps a stale ref from a crashed prior run.
|
||||
// update-ref -d on a missing ref exits 0.
|
||||
for (const ref of ['refs/seed/stash', 'refs/seed/root']) {
|
||||
await execFileNoThrowWithCwd(gitExe(), ['update-ref', '-d', ref], {
|
||||
cwd: gitRoot,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user