chore: initialize recovered claude workspace
This commit is contained in:
384
src/bridge/createSession.ts
Normal file
384
src/bridge/createSession.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { extractErrorDetail } from './debugUtils.js'
|
||||
import { toCompatSessionId } from './sessionIdCompat.js'
|
||||
|
||||
type GitSource = {
|
||||
type: 'git_repository'
|
||||
url: string
|
||||
revision?: string
|
||||
}
|
||||
|
||||
type GitOutcome = {
|
||||
type: 'git_repository'
|
||||
git_info: { type: 'github'; repo: string; branches: string[] }
|
||||
}
|
||||
|
||||
// Events must be wrapped in { type: 'event', data: <sdk_message> } for the
|
||||
// POST /v1/sessions endpoint (discriminated union format).
|
||||
type SessionEvent = {
|
||||
type: 'event'
|
||||
data: SDKMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on a bridge environment via POST /v1/sessions.
|
||||
*
|
||||
* Used by both `claude remote-control` (empty session so the user has somewhere to
|
||||
* type immediately) and `/remote-control` (session pre-populated with conversation
|
||||
* history).
|
||||
*
|
||||
* Returns the session ID on success, or null if creation fails (non-fatal).
|
||||
*/
|
||||
export async function createBridgeSession({
|
||||
environmentId,
|
||||
title,
|
||||
events,
|
||||
gitRepoUrl,
|
||||
branch,
|
||||
signal,
|
||||
baseUrl: baseUrlOverride,
|
||||
getAccessToken,
|
||||
permissionMode,
|
||||
}: {
|
||||
environmentId: string
|
||||
title?: string
|
||||
events: SessionEvent[]
|
||||
gitRepoUrl: string | null
|
||||
branch: string
|
||||
signal: AbortSignal
|
||||
baseUrl?: string
|
||||
getAccessToken?: () => string | undefined
|
||||
permissionMode?: string
|
||||
}): Promise<string | null> {
|
||||
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
||||
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||
const { parseGitHubRepository } = await import('../utils/detectRepository.js')
|
||||
const { getDefaultBranch } = await import('../utils/git.js')
|
||||
const { getMainLoopModel } = await import('../utils/model/model.js')
|
||||
const { default: axios } = await import('axios')
|
||||
|
||||
const accessToken =
|
||||
getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
logForDebugging('[bridge] No access token for session creation')
|
||||
return null
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logForDebugging('[bridge] No org UUID for session creation')
|
||||
return null
|
||||
}
|
||||
|
||||
// Build git source and outcome context
|
||||
let gitSource: GitSource | null = null
|
||||
let gitOutcome: GitOutcome | null = null
|
||||
|
||||
if (gitRepoUrl) {
|
||||
const { parseGitRemote } = await import('../utils/detectRepository.js')
|
||||
const parsed = parseGitRemote(gitRepoUrl)
|
||||
if (parsed) {
|
||||
const { host, owner, name } = parsed
|
||||
const revision = branch || (await getDefaultBranch()) || undefined
|
||||
gitSource = {
|
||||
type: 'git_repository',
|
||||
url: `https://${host}/${owner}/${name}`,
|
||||
revision,
|
||||
}
|
||||
gitOutcome = {
|
||||
type: 'git_repository',
|
||||
git_info: {
|
||||
type: 'github',
|
||||
repo: `${owner}/${name}`,
|
||||
branches: [`claude/${branch || 'task'}`],
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Fallback: try parseGitHubRepository for owner/repo format
|
||||
const ownerRepo = parseGitHubRepository(gitRepoUrl)
|
||||
if (ownerRepo) {
|
||||
const [owner, name] = ownerRepo.split('/')
|
||||
if (owner && name) {
|
||||
const revision = branch || (await getDefaultBranch()) || undefined
|
||||
gitSource = {
|
||||
type: 'git_repository',
|
||||
url: `https://github.com/${owner}/${name}`,
|
||||
revision,
|
||||
}
|
||||
gitOutcome = {
|
||||
type: 'git_repository',
|
||||
git_info: {
|
||||
type: 'github',
|
||||
repo: `${owner}/${name}`,
|
||||
branches: [`claude/${branch || 'task'}`],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
...(title !== undefined && { title }),
|
||||
events,
|
||||
session_context: {
|
||||
sources: gitSource ? [gitSource] : [],
|
||||
outcomes: gitOutcome ? [gitOutcome] : [],
|
||||
model: getMainLoopModel(),
|
||||
},
|
||||
environment_id: environmentId,
|
||||
source: 'remote-control',
|
||||
...(permissionMode && { permission_mode: permissionMode }),
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const url = `${baseUrlOverride ?? getOauthConfig().BASE_API_URL}/v1/sessions`
|
||||
let response
|
||||
try {
|
||||
response = await axios.post(url, requestBody, {
|
||||
headers,
|
||||
signal,
|
||||
validateStatus: s => s < 500,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(
|
||||
`[bridge] Session creation request failed: ${errorMessage(err)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
const isSuccess = response.status === 200 || response.status === 201
|
||||
|
||||
if (!isSuccess) {
|
||||
const detail = extractErrorDetail(response.data)
|
||||
logForDebugging(
|
||||
`[bridge] Session creation failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionData: unknown = response.data
|
||||
if (
|
||||
!sessionData ||
|
||||
typeof sessionData !== 'object' ||
|
||||
!('id' in sessionData) ||
|
||||
typeof sessionData.id !== 'string'
|
||||
) {
|
||||
logForDebugging('[bridge] No session ID in response')
|
||||
return null
|
||||
}
|
||||
|
||||
return sessionData.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a bridge session via GET /v1/sessions/{id}.
|
||||
*
|
||||
* Returns the session's environment_id (for `--session-id` resume) and title.
|
||||
* Uses the same org-scoped headers as create/archive — the environments-level
|
||||
* client in bridgeApi.ts uses a different beta header and no org UUID, which
|
||||
* makes the Sessions API return 404.
|
||||
*/
|
||||
export async function getBridgeSession(
|
||||
sessionId: string,
|
||||
opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
|
||||
): Promise<{ environment_id?: string; title?: string } | null> {
|
||||
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
||||
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||
const { default: axios } = await import('axios')
|
||||
|
||||
const accessToken =
|
||||
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
logForDebugging('[bridge] No access token for session fetch')
|
||||
return null
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logForDebugging('[bridge] No org UUID for session fetch')
|
||||
return null
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
|
||||
logForDebugging(`[bridge] Fetching session ${sessionId}`)
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await axios.get<{ environment_id?: string; title?: string }>(
|
||||
url,
|
||||
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(
|
||||
`[bridge] Session fetch request failed: ${errorMessage(err)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
const detail = extractErrorDetail(response.data)
|
||||
logForDebugging(
|
||||
`[bridge] Session fetch failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a bridge session via POST /v1/sessions/{id}/archive.
|
||||
*
|
||||
* The CCR server never auto-archives sessions — archival is always an
|
||||
* explicit client action. Both `claude remote-control` (standalone bridge) and the
|
||||
* always-on `/remote-control` REPL bridge call this during shutdown to archive any
|
||||
* sessions that are still alive.
|
||||
*
|
||||
* The archive endpoint accepts sessions in any status (running, idle,
|
||||
* requires_action, pending) and returns 409 if already archived, making
|
||||
* it safe to call even if the server-side runner already archived the
|
||||
* session.
|
||||
*
|
||||
* Callers must handle errors — this function has no try/catch; 5xx,
|
||||
* timeouts, and network errors throw. Archival is best-effort during
|
||||
* cleanup; call sites wrap with .catch().
|
||||
*/
|
||||
export async function archiveBridgeSession(
|
||||
sessionId: string,
|
||||
opts?: {
|
||||
baseUrl?: string
|
||||
getAccessToken?: () => string | undefined
|
||||
timeoutMs?: number
|
||||
},
|
||||
): Promise<void> {
|
||||
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
||||
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||
const { default: axios } = await import('axios')
|
||||
|
||||
const accessToken =
|
||||
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
logForDebugging('[bridge] No access token for session archive')
|
||||
return
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logForDebugging('[bridge] No org UUID for session archive')
|
||||
return
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive`
|
||||
logForDebugging(`[bridge] Archiving session ${sessionId}`)
|
||||
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{},
|
||||
{
|
||||
headers,
|
||||
timeout: opts?.timeoutMs ?? 10_000,
|
||||
validateStatus: s => s < 500,
|
||||
},
|
||||
)
|
||||
|
||||
if (response.status === 200) {
|
||||
logForDebugging(`[bridge] Session ${sessionId} archived successfully`)
|
||||
} else {
|
||||
const detail = extractErrorDetail(response.data)
|
||||
logForDebugging(
|
||||
`[bridge] Session archive failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the title of a bridge session via PATCH /v1/sessions/{id}.
|
||||
*
|
||||
* Called when the user renames a session via /rename while a bridge
|
||||
* connection is active, so the title stays in sync on claude.ai/code.
|
||||
*
|
||||
* Errors are swallowed — title sync is best-effort.
|
||||
*/
|
||||
export async function updateBridgeSessionTitle(
|
||||
sessionId: string,
|
||||
title: string,
|
||||
opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
|
||||
): Promise<void> {
|
||||
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
||||
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||
const { default: axios } = await import('axios')
|
||||
|
||||
const accessToken =
|
||||
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
logForDebugging('[bridge] No access token for session title update')
|
||||
return
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logForDebugging('[bridge] No org UUID for session title update')
|
||||
return
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
// Compat gateway only accepts session_* (compat/convert.go:27). v2 callers
|
||||
// pass raw cse_*; retag here so all callers can pass whatever they hold.
|
||||
// Idempotent for v1's session_* and bridgeMain's pre-converted compatSessionId.
|
||||
const compatId = toCompatSessionId(sessionId)
|
||||
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${compatId}`
|
||||
logForDebugging(`[bridge] Updating session title: ${compatId} → ${title}`)
|
||||
|
||||
try {
|
||||
const response = await axios.patch(
|
||||
url,
|
||||
{ title },
|
||||
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
|
||||
)
|
||||
|
||||
if (response.status === 200) {
|
||||
logForDebugging(`[bridge] Session title updated successfully`)
|
||||
} else {
|
||||
const detail = extractErrorDetail(response.data)
|
||||
logForDebugging(
|
||||
`[bridge] Session title update failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||
)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(
|
||||
`[bridge] Session title update request failed: ${errorMessage(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user