init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
+466
View File
@@ -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
}
}
+77
View File
@@ -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,
}
}
+120
View File
@@ -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
}
+292
View File
@@ -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,
})
}
}
}