Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions src/commands/agents/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
// TODO: Migrate to @netlify/api once these endpoints are public.
// They are marked x-internal in bitballoon (#21736).
// Method names mirror the bitballoon @operation_id to keep the future swap quick.
Comment on lines +1 to +3
Copy link
Copy Markdown
Contributor

@khendrikse khendrikse Jun 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] a smaller comment with less service specificity

Suggested change
// TODO: Migrate to @netlify/api once these endpoints are public.
// They are marked x-internal in bitballoon (#21736).
// Method names mirror the bitballoon @operation_id to keep the future swap quick.
// TODO: Migrate to @netlify/api once these endpoints are public.
// Method names mirror the backend @operation_id to keep the future swap quick.


import type { NetlifyOptions } from '../types.js'
import { parseLinkHeader } from './utils.js'
import type {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VaibhavAcharya I would have split this file up as well, start with 1 command and the methods we need for it, and the same for the types. This makes it easier to understand how they are being used in the following PR's that need to be reviewed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary to do now btw! But for a next time :)

AgentRunner,
AgentRunnerSession,
AiGatewayProvidersResponse,
CreateAgentRunnerPayload,
CreateAgentRunnerSessionPayload,
DeleteUrlResponse,
DiffParams,
ListAgentRunnerSessionsFilters,
ListAgentRunnersFilters,
PaginatedResult,
UploadUrlResponse,
} from './types.js'

const DEFAULT_PER_PAGE = 100

type RawResponseHandler<T> = (response: Response) => Promise<T>

type SearchParamValue = string | number | boolean | null | undefined

const buildSearchParams = (entries: Record<string, SearchParamValue>): URLSearchParams => {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(entries)) {
if (value === undefined || value === null || value === '') continue
params.set(key, value.toString())
}
return params
}

const readPagination = (response: Response, page: number, perPage: number): { total?: number; hasNext: boolean } => {
const totalHeader = response.headers.get('Total')
const total = totalHeader != null ? Number.parseInt(totalHeader, 10) : undefined
const links = parseLinkHeader(response.headers.get('Link'))
const hasNext = Boolean(links.next) || (total != null && page * perPage < total)
return { total: Number.isFinite(total) ? total : undefined, hasNext }
}

export const createAgentsApi = (netlify: NetlifyOptions) => {
const { api, apiOpts } = netlify
const baseUrl = api.basePath

const baseHeaders = (extra: Record<string, string> = {}): Record<string, string> => ({
Authorization: `Bearer ${api.accessToken ?? ''}`,
'User-Agent': apiOpts.userAgent,
...extra,
})

const throwForStatus = async (response: Response): Promise<never> => {
const errorData = (await response.json().catch(() => ({}))) as { error?: string }
const error = new Error(
errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`,
) as Error & { status?: number }
error.status = response.status
throw error
}

const requestRaw = async <T>(path: string, init: RequestInit, handler: RawResponseHandler<T>): Promise<T> => {
const response = await fetch(`${baseUrl}${path}`, init)
if (!response.ok) await throwForStatus(response)
return handler(response)
}

const requestJson = async <T>(path: string, init: RequestInit = {}): Promise<T> =>
requestRaw(path, init, async (response) => {
if (response.status === 202) return undefined as T
const text = await response.text()
if (!text) return undefined as T
return JSON.parse(text) as T
})

const requestNoContent = (path: string, init: RequestInit = {}): Promise<void> =>
requestRaw(path, init, () => Promise.resolve(undefined))

const jsonInit = (method: string, body?: unknown): RequestInit => ({
method,
headers: baseHeaders(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
body: body !== undefined ? JSON.stringify(body) : undefined,
})

const getInit = (): RequestInit => ({ method: 'GET', headers: baseHeaders() })

const listAgentRunners = async (
siteId: string,
filters: ListAgentRunnersFilters = {},
): Promise<PaginatedResult<AgentRunner[]>> => {
const page = filters.page ?? 1
const perPage = filters.per_page ?? DEFAULT_PER_PAGE
const params = buildSearchParams({ ...filters, site_id: siteId, page, per_page: perPage })
const response = await fetch(`${baseUrl}/agent_runners?${params.toString()}`, getInit())
if (!response.ok) await throwForStatus(response)
const data = (await response.json()) as AgentRunner[]
const { total, hasNext } = readPagination(response, page, perPage)
return { data, total, page, perPage, hasNext }
}

const listAgentRunnersForAccount = async (
accountSlug: string,
filters: ListAgentRunnersFilters = {},
): Promise<PaginatedResult<AgentRunner[]>> => {
const page = filters.page ?? 1
const perPage = filters.per_page ?? DEFAULT_PER_PAGE
const params = buildSearchParams({ ...filters, page, per_page: perPage })
const response = await fetch(
`${baseUrl}/${encodeURIComponent(accountSlug)}/agent_runners?${params.toString()}`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're using encodeURIComponent here, is there a reason we do not want to do a similar thing to other id's? like a session or AR id? Maybe this happens in the command itself :)

getInit(),
)
if (!response.ok) await throwForStatus(response)
const data = (await response.json()) as AgentRunner[]
const { total, hasNext } = readPagination(response, page, perPage)
return { data, total, page, perPage, hasNext }
}

const getAgentRunner = (id: string): Promise<AgentRunner> =>
requestJson<AgentRunner>(`/agent_runners/${id}`, getInit())

const createAgentRunner = (siteId: string, payload: CreateAgentRunnerPayload): Promise<AgentRunner> => {
const params = buildSearchParams({ site_id: siteId })
return requestJson<AgentRunner>(`/agent_runners?${params.toString()}`, jsonInit('POST', payload))
}

const deleteAgentRunner = (id: string): Promise<void> =>
requestNoContent(`/agent_runners/${id}`, { method: 'DELETE', headers: baseHeaders() })

const archiveAgentRunner = (id: string): Promise<void> =>
requestNoContent(`/agent_runners/${id}/archive`, { method: 'POST', headers: baseHeaders() })

const listAgentRunnerSessions = async (
id: string,
filters: ListAgentRunnerSessionsFilters = {},
): Promise<AgentRunnerSession[]> => {
const page = filters.page ?? 1
const perPage = filters.per_page ?? DEFAULT_PER_PAGE
const params = buildSearchParams({ ...filters, page, per_page: perPage })
return requestJson<AgentRunnerSession[]>(`/agent_runners/${id}/sessions?${params.toString()}`, getInit())
}

const getAgentRunnerSession = (id: string, sessionId: string): Promise<AgentRunnerSession> =>
requestJson<AgentRunnerSession>(`/agent_runners/${id}/sessions/${sessionId}`, getInit())

const createAgentRunnerSession = (
id: string,
payload: CreateAgentRunnerSessionPayload,
): Promise<AgentRunnerSession> =>
requestJson<AgentRunnerSession>(`/agent_runners/${id}/sessions`, jsonInit('POST', payload))

const redeployAgentRunnerSession = (id: string, sessionId: string): Promise<AgentRunnerSession> =>
requestJson<AgentRunnerSession>(`/agent_runners/${id}/sessions/${sessionId}/redeploy`, jsonInit('POST'))

const getAgentRunnerDiff = async (id: string, params: DiffParams = {}): Promise<PaginatedResult<string>> => {
const page = params.page ?? 1
const perPage = params.per_page ?? DEFAULT_PER_PAGE
const stripBinary = params.strip_binary ?? true
const search = buildSearchParams({ page, per_page: perPage, strip_binary: stripBinary })
const response = await fetch(`${baseUrl}/agent_runners/${id}/diff?${search.toString()}`, getInit())
if (!response.ok) {
if (response.status === 404) return { data: '', total: 0, page, perPage, hasNext: false }
await throwForStatus(response)
}
const body = await response.text()
const { total, hasNext } = readPagination(response, page, perPage)
return { data: body, total, page, perPage, hasNext }
}

const getSessionDiff = async (id: string, sessionId: string, kind: 'result' | 'cumulative'): Promise<string> => {
const response = await fetch(`${baseUrl}/agent_runners/${id}/sessions/${sessionId}/diff/${kind}`, getInit())
if (response.status === 404) return ''
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd maybe flip this with the next line for consistency. But ok to do that in a next PR :)

if (!response.ok) await throwForStatus(response)
return response.text()
}

const agentRunnerPullRequest = (id: string): Promise<AgentRunner> =>
requestJson<AgentRunner>(`/agent_runners/${id}/pull_request`, jsonInit('POST'))

const agentRunnerCommitToBranch = (id: string, targetBranch: string): Promise<AgentRunner> =>
requestJson<AgentRunner>(`/agent_runners/${id}/commit`, jsonInit('POST', { target_branch: targetBranch }))

const agentRunnerPublishToProduction = (id: string): Promise<AgentRunner> =>
requestJson<AgentRunner>(`/agent_runners/${id}/publish_to_production`, jsonInit('POST'))

const revertAgentRunner = (id: string, sessionId: string): Promise<AgentRunner> =>
requestJson<AgentRunner>(`/agent_runners/${id}/revert`, jsonInit('POST', { session_id: sessionId }))

const updateAgentRunner = (id: string, payload: { title?: string; base_deploy_id?: string }): Promise<AgentRunner> =>
requestJson<AgentRunner>(`/agent_runners/${id}`, jsonInit('PATCH', payload))

const rebaseAgentRunner = (id: string): Promise<AgentRunner> =>
requestJson<AgentRunner>(`/agent_runners/${id}/rebase`, jsonInit('POST'))

const mergeTargetAgentRunner = (id: string): Promise<AgentRunner> =>
requestJson<AgentRunner>(`/agent_runners/${id}/merge_target`, jsonInit('POST'))

const syncGitOriginAgentRunner = (id: string): Promise<AgentRunner> =>
requestJson<AgentRunner>(`/agent_runners/${id}/sync_git_origin`, jsonInit('POST'))

const createAgentRunnerUploadUrl = (payload: {
account_id: string
filename: string
content_type: string
}): Promise<UploadUrlResponse> =>
requestJson<UploadUrlResponse>(`/agent_runners/upload_url`, jsonInit('POST', payload))

const createAgentRunnerDeleteUrl = (payload: { account_id: string; file_key: string }): Promise<DeleteUrlResponse> =>
requestJson<DeleteUrlResponse>(`/agent_runners/delete_url`, jsonInit('POST', payload))

let providersCache: AiGatewayProvidersResponse | null = null
const listAiGatewayProviders = async (): Promise<AiGatewayProvidersResponse> => {
if (providersCache) return providersCache
// Public endpoint by design — no auth header. The provider+model list is meant
// for external clients to discover the agent → provider → model relationship.
const response = await fetch(`${baseUrl}/ai-gateway/providers`)
if (!response.ok) await throwForStatus(response)
providersCache = (await response.json()) as AiGatewayProvidersResponse
return providersCache
}

return {
listAgentRunners,
listAgentRunnersForAccount,
getAgentRunner,
createAgentRunner,
updateAgentRunner,
deleteAgentRunner,
archiveAgentRunner,
listAgentRunnerSessions,
getAgentRunnerSession,
createAgentRunnerSession,
redeployAgentRunnerSession,
getAgentRunnerDiff,
getSessionResultDiff: (id: string, sessionId: string) => getSessionDiff(id, sessionId, 'result'),
getSessionCumulativeDiff: (id: string, sessionId: string) => getSessionDiff(id, sessionId, 'cumulative'),
agentRunnerPullRequest,
agentRunnerCommitToBranch,
agentRunnerPublishToProduction,
revertAgentRunner,
rebaseAgentRunner,
mergeTargetAgentRunner,
syncGitOriginAgentRunner,
createAgentRunnerUploadUrl,
createAgentRunnerDeleteUrl,
listAiGatewayProviders,
}
}

export type AgentsApi = ReturnType<typeof createAgentsApi>
112 changes: 112 additions & 0 deletions src/commands/agents/attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import fs from 'fs/promises'
import path from 'path'

import type { AgentsApi } from './api.js'
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS_PER_REQUEST } from './constants.js'
import { formatBytes, getMimeType } from './utils.js'

export interface UploadedAttachment {
path: string
filename: string
fileKey: string
size: number
contentType: string
}

const cleanupOrphans = async (api: AgentsApi, accountId: string, fileKeys: string[]): Promise<void> => {
await Promise.allSettled(
fileKeys.map(async (fileKey) => {
try {
const { delete_url: deleteUrl } = await api.createAgentRunnerDeleteUrl({
account_id: accountId,
file_key: fileKey,
})
await fetch(deleteUrl, { method: 'DELETE' })
} catch {
// Best-effort cleanup; if it fails, the orphan is the user's tenant problem.
}
}),
)
}

export const uploadAttachments = async (
api: AgentsApi,
accountId: string,
filePaths: string[],
): Promise<UploadedAttachment[]> => {
if (filePaths.length === 0) return []
if (filePaths.length > MAX_ATTACHMENTS_PER_REQUEST) {
throw new Error(
`Too many attachments: ${filePaths.length.toString()} given, max is ${MAX_ATTACHMENTS_PER_REQUEST.toString()}`,
)
}

const resolved = await Promise.all(
filePaths.map(async (filePath) => {
const absolute = path.resolve(filePath)
const stat = await fs.stat(absolute).catch(() => null)
if (!stat?.isFile()) {
throw new Error(`Attachment not found or not a file: ${filePath}`)
}
if (stat.size > MAX_ATTACHMENT_SIZE_BYTES) {
throw new Error(
`Attachment ${filePath} is ${formatBytes(stat.size)}, exceeds the ${formatBytes(
MAX_ATTACHMENT_SIZE_BYTES,
)} limit`,
)
}
const filename = path.basename(absolute)
return { path: absolute, filename, size: stat.size, contentType: getMimeType(filename) }
}),
)

const uploaded: UploadedAttachment[] = []
try {
for (const file of resolved) {
const { upload_url: uploadUrl, file_key: fileKey } = await api.createAgentRunnerUploadUrl({
account_id: accountId,
filename: file.filename,
content_type: file.contentType,
})

const body = await fs.readFile(file.path)
const controller = new AbortController()
const timeout = setTimeout(() => {
controller.abort()
}, 60_000)
let putResponse: Response
try {
putResponse = await fetch(uploadUrl, {
method: 'PUT',
body: new Uint8Array(body),
headers: { 'Content-Type': file.contentType },
signal: controller.signal,
})
} catch (error_) {
const error = error_ as Error
if (error.name === 'AbortError') {
throw new Error(`Upload of ${file.filename} timed out after 60s`)
}
throw error
} finally {
clearTimeout(timeout)
}
if (!putResponse.ok) {
throw new Error(
`Failed to upload ${file.filename}: HTTP ${putResponse.status.toString()} ${putResponse.statusText}`,
)
}
uploaded.push({ ...file, fileKey })
}
return uploaded
} catch (error) {
if (uploaded.length > 0) {
await cleanupOrphans(
api,
accountId,
uploaded.map((entry) => entry.fileKey),
)
}
throw error
}
}
Loading
Loading