Skip to content
← Public packages

@kentcdodds/devin

Start, monitor, and manage Devin sessions, knowledge, playbooks, and schedules via the Devin v3 API

src/sessions.ts

382 lines · 10.8 KB · TypeScript
import {
	clampInt,
	compact,
	devinOrgApi,
	type Paginated,
	trimString,
} from './lib/client.ts'

export type SessionStatus =
	| 'RUNNING'
	| 'blocked'
	| 'stopped'
	| 'expired'
	| 'finished'
	| string

export type SessionPullRequest = {
	url?: string | null
	title?: string | null
	state?: string | null
	[key: string]: unknown
}

export type Session = {
	/**
	 * Session id returned by Devin. Pass this value as **`devinId`** to
	 * getSession / listMessages / sendMessage / etc. Do not rename it to
	 * `sessionId` in helper args — that prop is not accepted and causes 403s.
	 */
	session_id: string
	url: string
	status: SessionStatus
	status_detail?: string | null
	title?: string | null
	tags: string[]
	org_id: string
	user_id?: string | null
	service_user_id?: string | null
	created_at: number
	updated_at: number
	acus_consumed: number
	is_archived?: boolean | null
	category?: string | null
	subcategory?: string | null
	origin?: string | null
	devin_mode?: string | null
	playbook_id?: string | null
	parent_session_id?: string | null
	child_session_ids?: string[] | null
	structured_output?: unknown
	pull_requests: SessionPullRequest[]
}

export type SessionMessage = {
	event_id?: string | null
	message?: string | null
	source?: string | null
	created_at?: number | null
}

export type SessionAttachment = {
	attachment_id?: string | null
	name?: string | null
	content_type?: string | null
	source?: string | null
	url?: string | null
}

/**
 * Params for helpers that operate on one session.
 *
 * Pass **`devinId`** — use the session's `session_id` field from create/list
 * responses (e.g. `session.session_id`). There is **no** `sessionId` argument on
 * these methods; using `{ sessionId }` leaves `devinId` undefined and Devin
 * often responds with HTTP **403**, which looks like an auth failure but is
 * usually the wrong field name.
 */
export type SessionByIdParams = {
	/**
	 * Session id for path `/sessions/{devinId}`.
	 * Value is `Session.session_id` from createSession / listSessions / getSession.
	 * Do **not** pass `sessionId` — that prop is not accepted here.
	 */
	devinId: string
	orgId?: string
}

/**
 * Resolve `devinId` for per-session helpers. Rejects mistaken `sessionId`.
 */
export function requireDevinId(
	params: SessionByIdParams & { sessionId?: unknown },
	helperName: string,
): string {
	if (params != null && 'sessionId' in params && (params as { sessionId?: unknown }).sessionId != null) {
		throw new Error(
			`${helperName}: use { devinId } (Session.session_id), not { sessionId }. ` +
				`Passing sessionId is ignored and Devin typically returns HTTP 403.`,
		)
	}
	const id = trimString(params?.devinId ?? '')
	if (!id) {
		throw new Error(
			`${helperName}: requires { devinId: session.session_id }. ` +
				`Do not pass sessionId — a 403 here is most often the wrong field name.`,
		)
	}
	return id
}

export type ListSessionsParams = {
	orgId?: string
	/** Page size, 1–200 (Devin default 100). */
	first?: number
	/** Cursor from a previous page's `end_cursor`. */
	after?: string
	isArchived?: boolean
	category?: string
	origins?: string[]
	repoNames?: string[]
	sessionIds?: string[]
	serviceUserIds?: string[]
	userIds?: string[]
	tags?: string[]
	playbookId?: string
	scheduleId?: string
	/** Unix seconds. */
	createdAfter?: number
	createdBefore?: number
	updatedAfter?: number
	updatedBefore?: number
}

function listQuery(params: ListSessionsParams = {}) {
	return compact({
		first: params.first === undefined ? undefined : clampInt(params.first, 1, 200, 100),
		after: params.after,
		is_archived: params.isArchived,
		category: params.category,
		origins: params.origins,
		repo_names: params.repoNames,
		session_ids: params.sessionIds,
		service_user_ids: params.serviceUserIds,
		user_ids: params.userIds,
		tags: params.tags,
		playbook_id: params.playbookId,
		schedule_id: params.scheduleId,
		created_after: params.createdAfter,
		created_before: params.createdBefore,
		updated_after: params.updatedAfter,
		updated_before: params.updatedBefore,
	})
}

/** Cursor-paginated org sessions, newest first. */
export async function listSessions(params: ListSessionsParams = {}) {
	return devinOrgApi<Paginated<Session>>({
		orgId: params.orgId,
		path: '/sessions',
		query: listQuery(params),
	})
}

/** Follow `end_cursor` until `maxPages` or the last page. */
export async function listAllSessions(
	params: ListSessionsParams & { maxPages?: number } = {},
) {
	const maxPages = clampInt(params.maxPages ?? 5, 1, 20, 5)
	const items: Session[] = []
	let after = params.after
	let pages = 0

	while (pages < maxPages) {
		const page = await listSessions({ ...params, after })
		items.push(...page.items)
		pages += 1
		if (!page.has_next_page || !page.end_cursor) {
			return { items, pages, truncated: false, end_cursor: page.end_cursor ?? null }
		}
		after = page.end_cursor
	}

	return { items, pages, truncated: true, end_cursor: after ?? null }
}

export type CreateSessionParams = {
	orgId?: string
	prompt: string
	title?: string
	tags?: string[]
	repos?: string[]
	playbookId?: string
	childPlaybookId?: string
	knowledgeIds?: string[]
	secretIds?: string[]
	attachmentUrls?: string[]
	sessionLinks?: string[]
	/** Requires ImpersonateOrgSessions on the service user's role. */
	createAsUserId?: string
	devinMode?: string
	platform?: string
	maxAcuLimit?: number
	bypassApproval?: boolean
	resumable?: boolean
	structuredOutputRequired?: boolean
	structuredOutputSchema?: Record<string, unknown>
	sessionSecrets?: Array<Record<string, unknown>>
}

/** Start a session. Sessions consume ACUs — confirm intent before calling. */
export async function createSession(params: CreateSessionParams) {
	const prompt = trimString(params.prompt)
	if (!prompt) throw new Error('createSession requires a non-empty prompt')

	return devinOrgApi<Session>({
		orgId: params.orgId,
		path: '/sessions',
		method: 'POST',
		body: compact({
			prompt,
			title: params.title,
			tags: params.tags,
			repos: params.repos,
			playbook_id: params.playbookId,
			child_playbook_id: params.childPlaybookId,
			knowledge_ids: params.knowledgeIds,
			secret_ids: params.secretIds,
			attachment_urls: params.attachmentUrls,
			session_links: params.sessionLinks,
			create_as_user_id: params.createAsUserId,
			devin_mode: params.devinMode,
			platform: params.platform,
			max_acu_limit: params.maxAcuLimit,
			bypass_approval: params.bypassApproval,
			resumable: params.resumable,
			structured_output_required: params.structuredOutputRequired,
			structured_output_schema: params.structuredOutputSchema,
			session_secrets: params.sessionSecrets,
		}),
	})
}

/**
 * Load one session by **`devinId`** (`Session.session_id`).
 * @example await getSession({ devinId: session.session_id })
 * // not getSession({ sessionId: ... }) — that yields 403
 */
export async function getSession(params: SessionByIdParams) {
	const devinId = requireDevinId(params, 'getSession')
	return devinOrgApi<Session>({
		orgId: params.orgId,
		path: `/sessions/${encodeURIComponent(devinId)}`,
	})
}

/** Session status plus the fields worth surfacing in a status report. Uses `devinId` only. */
export async function getSessionStatus(params: SessionByIdParams) {
	const session = await getSession(params)
	return {
		session_id: session.session_id,
		title: session.title ?? null,
		status: session.status,
		status_detail: session.status_detail ?? null,
		url: session.url,
		acus_consumed: session.acus_consumed,
		updated_at: session.updated_at,
		is_archived: session.is_archived ?? null,
		pull_requests: session.pull_requests,
		structured_output: session.structured_output ?? null,
	}
}

/**
 * List messages for a session. Pass **`devinId`** (`Session.session_id`), not `sessionId`.
 * @example await listMessages({ devinId: '…' })
 */
export async function listMessages(
	params: SessionByIdParams & { first?: number; after?: string },
) {
	const devinId = requireDevinId(params, 'listMessages')
	return devinOrgApi<Paginated<SessionMessage>>({
		orgId: params.orgId,
		path: `/sessions/${encodeURIComponent(devinId)}/messages`,
		query: compact({
			first: params.first === undefined ? undefined : clampInt(params.first, 1, 200, 50),
			after: params.after,
		}),
	})
}

/** Send a message to a running session (queues if the session is busy). */
export async function sendMessage(
	params: SessionByIdParams & {
		message: string
		attachmentUrls?: string[]
		/** Requires ImpersonateOrgSessions on the service user's role. */
		messageAsUserId?: string
	},
) {
	const devinId = requireDevinId(params, 'sendMessage')
	const message = trimString(params.message)
	if (!message) throw new Error('sendMessage requires a non-empty message')

	return devinOrgApi<unknown>({
		orgId: params.orgId,
		path: `/sessions/${encodeURIComponent(devinId)}/messages`,
		method: 'POST',
		body: compact({
			message,
			attachment_urls: params.attachmentUrls,
			message_as_user_id: params.messageAsUserId,
		}),
	})
}

/** Terminate a session's VM. Destructive — the session cannot be resumed. */
export async function terminateSession(
	params: SessionByIdParams & {
		/** Also archive the session as part of terminating it. */
		archive?: boolean
	},
) {
	const devinId = requireDevinId(params, 'terminateSession')
	return devinOrgApi<unknown>({
		orgId: params.orgId,
		path: `/sessions/${encodeURIComponent(devinId)}`,
		method: 'DELETE',
		query: compact({ archive: params.archive }),
	})
}

export async function archiveSession(params: SessionByIdParams) {
	const devinId = requireDevinId(params, 'archiveSession')
	return devinOrgApi<unknown>({
		orgId: params.orgId,
		path: `/sessions/${encodeURIComponent(devinId)}/archive`,
		method: 'POST',
	})
}

export async function unarchiveSession(params: SessionByIdParams) {
	const devinId = requireDevinId(params, 'unarchiveSession')
	return devinOrgApi<unknown>({
		orgId: params.orgId,
		path: `/sessions/${encodeURIComponent(devinId)}/unarchive`,
		method: 'POST',
	})
}

export async function getSessionTags(params: SessionByIdParams) {
	const devinId = requireDevinId(params, 'getSessionTags')
	return devinOrgApi<{ tags: string[] }>({
		orgId: params.orgId,
		path: `/sessions/${encodeURIComponent(devinId)}/tags`,
	})
}

/** `mode: 'replace'` (PUT) overwrites tags; `'add'` (POST) appends them. */
export async function setSessionTags(
	params: SessionByIdParams & {
		tags: string[]
		mode?: 'replace' | 'add'
	},
) {
	const devinId = requireDevinId(params, 'setSessionTags')
	return devinOrgApi<{ tags: string[] }>({
		orgId: params.orgId,
		path: `/sessions/${encodeURIComponent(devinId)}/tags`,
		method: params.mode === 'add' ? 'POST' : 'PUT',
		body: { tags: params.tags },
	})
}

export async function listSessionAttachments(params: SessionByIdParams) {
	const devinId = requireDevinId(params, 'listSessionAttachments')
	return devinOrgApi<{ items: SessionAttachment[] } | SessionAttachment[]>({
		orgId: params.orgId,
		path: `/sessions/${encodeURIComponent(devinId)}/attachments`,
	})
}

export default listSessions