Skip to content
← Public packages

@kody/salesforce

Salesforce REST helpers for identity, SOQL, multi-org routing, and error investigation.

src/triage-error.ts

1183 lines · 34.9 KB · TypeScript
import { describeSObject } from './describe-sobject.ts'
import { getRecord } from './get-record.ts'
import {
	extractOrganizationId,
	lookupOrgMap,
	normalizeOrganizationId,
	resolveOrg,
	type OrgMap,
} from './orgs.ts'
import { query } from './query.ts'
import {
	SalesforceApiError,
	getInstanceUrl,
	request,
	resolveIntegrationName,
} from './request.ts'
import type { JsonRecord } from './types.ts'
import {
	optionalBoolean,
	optionalString,
	requireRecord,
	requireSalesforceId,
} from './types.ts'

export type ErrorFamily =
	| 'validation'
	| 'dml'
	| 'query'
	| 'auth'
	| 'apex'
	| 'flow'
	| 'limits'
	| 'api'
	| 'unknown'

export type SalesforceIdHint = {
	id: string
	prefix: string
	likelySObject: string | null
}

export type TriageErrorInput = {
	/** Loose REST body, Flow fault email, Apex exception, or any JSON/text. */
	error?: unknown
	message?: string
	text?: string
	body?: unknown
	payload?: unknown
	statusCode?: number
	status?: number
	recordId?: string
	recordIds?: Array<string>
	flowId?: string
	flowName?: string
	classId?: string
	className?: string
	sobject?: string
	fields?: Array<string> | string
	instanceUrl?: string
	apiVersion?: string
	/** Salesforce Organization id (00D…). Used to pick the right connection. */
	organizationId?: string
	/** Caller-owned organizationId → integrationName map from packageStorage. */
	orgMap?: OrgMap
	/** Saved OAuth integration name. Defaults to `salesforce` only when no org id is present. */
	integrationName?: string
	/** Skip org lookups and return the local briefing plus suggested queries. */
	dryRun?: boolean
}

export type TriageLookup = {
	kind: string
	ok: boolean
	skipped?: string
	summary?: JsonRecord
}

export type SuggestedQuery = {
	kind: 'soql' | 'tooling'
	q: string
	why: string
}

export type ErrorBriefing = {
	family: ErrorFamily
	statusCode: number | null
	errorCodes: Array<string>
	message: string | null
	fields: Array<string>
	ids: Array<SalesforceIdHint>
	likelySObjects: Array<string>
	flow: {
		name: string | null
		id: string | null
		interviewId: string | null
		element: string | null
	}
	apex: {
		className: string | null
		classId: string | null
		line: number | null
		exceptionType: string | null
	}
}

export type TriageErrorResult = {
	dryRun: boolean
	integrationName: string
	organizationId: string | null
	orgSource: 'explicit' | 'org-map' | 'live-discovery' | 'default'
	briefing: ErrorBriefing
	lookups: Array<TriageLookup>
	suggestedQueries: Array<SuggestedQuery>
	summary: string
}

const SOBJECT_PREFIXES: Record<string, string> = {
	'001': 'Account',
	'003': 'Contact',
	'005': 'User',
	'006': 'Opportunity',
	'00D': 'Organization',
	'00G': 'Group',
	'00Q': 'Lead',
	'00T': 'Task',
	'00U': 'Event',
	'00e': 'Profile',
	'00k': 'OpportunityLineItem',
	'01I': 'CustomObject',
	'01p': 'ApexClass',
	'01q': 'ApexTrigger',
	'01t': 'Product2',
	'03d': 'ValidationRule',
	'07L': 'ApexLog',
	'0QT': 'Quote',
	'300': 'FlowInterview',
	'301': 'Flow',
	'308': 'FlowDefinition',
	'500': 'Case',
	'701': 'Campaign',
	'800': 'Contract',
	'801': 'Order',
}

const FAMILY_BY_CODE: Record<string, ErrorFamily> = {
	CANNOT_INSERT_UPDATE_ACTIVATE_ENTITY: 'dml',
	DUPLICATE_VALUE: 'dml',
	ENTITY_IS_DELETED: 'dml',
	FIELD_CUSTOM_VALIDATION_EXCEPTION: 'validation',
	FIELD_FILTER_VALIDATION_EXCEPTION: 'validation',
	FIELD_INTEGRITY_EXCEPTION: 'validation',
	INSUFFICIENT_ACCESS: 'auth',
	INSUFFICIENT_ACCESS_ON_CROSS_REFERENCE_ENTITY: 'auth',
	INSUFFICIENT_ACCESS_OR_READONLY: 'auth',
	INVALID_EMAIL_ADDRESS: 'validation',
	INVALID_FIELD: 'query',
	INVALID_FIELD_FOR_INSERT_UPDATE: 'validation',
	INVALID_LOGIN: 'auth',
	INVALID_QUERY_FILTER_OPERATOR: 'query',
	INVALID_SESSION_ID: 'auth',
	INVALID_TYPE: 'query',
	MALFORMED_QUERY: 'query',
	MALFORMED_SEARCH: 'query',
	REQUEST_LIMIT_EXCEEDED: 'limits',
	REQUIRED_FIELD_MISSING: 'validation',
	STORAGE_LIMIT_EXCEEDED: 'limits',
	STRING_TOO_LONG: 'validation',
	UNABLE_TO_LOCK_ROW: 'dml',
}

const ID_PATTERN = /\b[a-zA-Z0-9]{15}(?:[a-zA-Z0-9]{3})?\b/g
const ERROR_CODE_PATTERN = /\b[A-Z][A-Z0-9_]{2,}\b/g
const FIELD_LIST_PATTERN = /\[([A-Za-z][A-Za-z0-9_,.\s]*)\]/g
const APEX_CLASS_PATTERN = /Class\.([A-Za-z][A-Za-z0-9_]+)/
const APEX_LINE_PATTERN = /line (\d+)/i
const APEX_EXCEPTION_PATTERN = /\b((?:System|Flow)\.[A-Za-z][A-Za-z0-9_]+)\b/
const FLOW_NAME_PATTERN = /Flow API Name:\s*([A-Za-z][A-Za-z0-9_]+)/i
const FLOW_ELEMENT_PATTERN =
	/(?:An error occurred at element|Current Element:)\s*([A-Za-z][A-Za-z0-9_]+)/i
const MAX_LOOKUPS = 6
const MAX_LOG_CHARS = 1800

type OrgContext = {
	integrationName: string
	instanceUrl?: string
	apiVersion?: string
}

/**
 * Normalize a Salesforce REST, Flow, or Apex error and, when useful, run read-only org lookups. Never mutates Salesforce.
 * @param input.error - Raw Salesforce error payload.
 * @param input.sobject - Optional sObject context for field lookups.
 * @returns Error briefing with optional read-only enrichment.
 * @example
 * import triageError from 'kody:@kody/salesforce/triage-error'
 * const briefing = await triageError({
 *   error: [{ errorCode: 'REQUIRED_FIELD_MISSING', fields: ['LastName'] }],
 *   sobject: 'Lead',
 *   dryRun: true,
 * })
 */
export async function triageError(input: TriageErrorInput): Promise<TriageErrorResult> {
	const resolved = await resolveTriageOrg(input)
	const integrationName = resolved.integrationName
	const briefing = normalizeError(input)
	const suggestedQueries = buildSuggestedQueries(briefing)
	const dryRun = input.dryRun === true

	if (dryRun) {
		return {
			dryRun: true,
			integrationName,
			organizationId: resolved.organizationId,
			orgSource: resolved.orgSource,
			briefing,
			lookups: [
				{
					kind: 'org',
					ok: false,
					skipped: 'dryRun: local briefing only; org lookups are not sent.',
				},
			],
			suggestedQueries,
			summary: writeSummary(briefing, true),
		}
	}

	const org: OrgContext = {
		integrationName,
		instanceUrl: optionalString(input.instanceUrl, 'instanceUrl'),
		apiVersion: optionalString(input.apiVersion, 'apiVersion'),
	}

	const lookups: Array<TriageLookup> = []
	try {
		const instanceUrl = await getInstanceUrl(org.instanceUrl, integrationName)
		lookups.push({
			kind: 'connection',
			ok: true,
			summary: { instanceHost: new URL(instanceUrl).host },
		})
		await collectOrgLookups(briefing, org, lookups)
	} catch (error) {
		lookups.push({
			kind: 'connection',
			ok: false,
			skipped: projectError(error),
		})
	}

	return {
		dryRun: false,
		integrationName,
		organizationId: resolved.organizationId,
		orgSource: resolved.orgSource,
		briefing,
		lookups,
		suggestedQueries,
		summary: writeSummary(briefing, false),
	}
}

async function resolveTriageOrg(input: TriageErrorInput): Promise<{
	integrationName: string
	organizationId: string | null
	orgSource: TriageErrorResult['orgSource']
}> {
	const organizationId = input.organizationId?.trim()
		? normalizeOrganizationId(input.organizationId)
		: extractOrganizationId(input.error, input.body, input.payload)

	if (input.integrationName?.trim()) {
		return {
			integrationName: resolveIntegrationName(input.integrationName),
			organizationId,
			orgSource: 'explicit',
		}
	}

	if (!organizationId) {
		return {
			integrationName: resolveIntegrationName(undefined),
			organizationId: null,
			orgSource: 'default',
		}
	}

	if (input.dryRun === true) {
		const mapped = lookupOrgMap(input.orgMap, organizationId)
		if (mapped) {
			return {
				integrationName: mapped.integrationName,
				organizationId,
				orgSource: 'org-map',
			}
		}
		throw new Error(
			`triage-error dryRun found organization ${organizationId} but no integrationName or orgMap entry. Pass one so this does not fall back to the default salesforce connection.`,
		)
	}

	const resolved = await resolveOrg({
		organizationId,
		orgMap: input.orgMap,
		payload: input.payload,
		error: input.error,
		body: input.body,
	})
	return {
		integrationName: resolved.integrationName,
		organizationId: resolved.organizationId,
		orgSource: resolved.source,
	}
}

/**
 * Normalize a Salesforce REST, Flow, or Apex error and, when useful, run
 * read-only org lookups. Never mutates Salesforce.
 * @example
 * import triageError from 'kody:@kody/salesforce/triage-error'
 * const briefing = await triageError({
 *   error: [{ errorCode: 'REQUIRED_FIELD_MISSING', fields: ['LastName'] }],
 *   sobject: 'Lead',
 *   dryRun: true,
 * })
 */
export default async function triageErrorEntrypoint(params: unknown = {}) {
	if (typeof params === 'string' || Array.isArray(params)) {
		return triageError({ error: params })
	}
	const input = requireRecord(params, 'triage-error')
	const recordIds = Array.isArray(input.recordIds)
		? input.recordIds.filter((id): id is string => typeof id === 'string')
		: undefined
	const fields = Array.isArray(input.fields)
		? input.fields.filter((field): field is string => typeof field === 'string')
		: optionalString(input.fields, 'fields')
	return triageError({
		error: input.error,
		message: optionalString(input.message, 'message'),
		text: optionalString(input.text, 'text'),
		body: input.body,
		payload: input.payload,
		statusCode: optionalLooseNumber(input.statusCode),
		status: optionalLooseNumber(input.status),
		recordId: optionalString(input.recordId, 'recordId'),
		recordIds,
		flowId: optionalString(input.flowId, 'flowId'),
		flowName: optionalString(input.flowName, 'flowName'),
		classId: optionalString(input.classId, 'classId'),
		className: optionalString(input.className, 'className'),
		sobject: optionalString(input.sobject, 'sobject'),
		fields,
		instanceUrl: optionalString(input.instanceUrl, 'instanceUrl'),
		apiVersion: optionalString(input.apiVersion, 'apiVersion'),
		organizationId: optionalString(input.organizationId, 'organizationId'),
		orgMap: isOrgMap(input.orgMap) ? input.orgMap : undefined,
		integrationName: optionalString(input.integrationName, 'integrationName'),
		dryRun: optionalBoolean(input.dryRun, 'dryRun'),
	})
}

function isOrgMap(value: unknown): value is OrgMap {
	return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}

export function normalizeError(input: TriageErrorInput): ErrorBriefing {
	const fragments = collectFragments(input)
	const text = fragments.map((fragment) => fragment.text).join('\n')
	const errorCodes = unique(fragments.flatMap((fragment) => fragment.errorCodes))
	const fields = unique([
		...explicitFields(input.fields),
		...fragments.flatMap((fragment) => fragment.fields),
		...extractFieldsFromText(text),
	])
	const ids = uniqueIds([
		...explicitIds(input),
		...fragments.flatMap((fragment) => fragment.ids),
		...extractIdsFromText(text),
	])
	const sobjects = unique([
		...(input.sobject ? [input.sobject] : []),
		...ids
			.map((id) => id.likelySObject)
			.filter((name): name is string => Boolean(name) && name !== 'CustomObject'),
	])
	const flowName =
		optionalTrim(input.flowName) ?? firstMatch(text, FLOW_NAME_PATTERN)
	const flowId =
		optionalTrim(input.flowId) ??
		ids.find((id) => id.likelySObject === 'Flow')?.id ??
		null
	const interviewId =
		ids.find((id) => id.likelySObject === 'FlowInterview')?.id ?? null
	const className =
		optionalTrim(input.className) ?? firstMatch(text, APEX_CLASS_PATTERN)
	const classId =
		optionalTrim(input.classId) ??
		ids.find((id) => id.likelySObject === 'ApexClass')?.id ??
		null
	const exceptionType = firstMatch(text, APEX_EXCEPTION_PATTERN)
	const line = Number.parseInt(firstMatch(text, APEX_LINE_PATTERN) ?? '', 10)
	const family = classifyFamily({
		errorCodes,
		text,
		flowName,
		className,
		exceptionType,
	})
	const message = pickMessage(fragments, input)

	return {
		family,
		statusCode: pickStatusCode(input, fragments, family),
		errorCodes,
		message,
		fields,
		ids,
		likelySObjects: sobjects,
		flow: {
			name: flowName,
			id: flowId,
			interviewId,
			element: firstMatch(text, FLOW_ELEMENT_PATTERN),
		},
		apex: {
			className,
			classId,
			line: Number.isFinite(line) ? line : null,
			exceptionType,
		},
	}
}

async function collectOrgLookups(
	briefing: ErrorBriefing,
	org: OrgContext,
	lookups: Array<TriageLookup>,
) {
	const sobject = briefing.likelySObjects[0]
	if (sobject && briefing.fields.length > 0 && lookups.length < MAX_LOOKUPS) {
		lookups.push(await lookupDescribe(sobject, briefing.fields, org))
	}

	for (const hint of briefing.ids) {
		if (lookups.length >= MAX_LOOKUPS) break
		if (!hint.likelySObject || hint.likelySObject === 'CustomObject') continue
		if (hint.likelySObject === 'ApexClass') continue
		if (hint.likelySObject === 'ApexLog') continue
		if (hint.likelySObject === 'Flow') continue
		if (hint.likelySObject === 'FlowDefinition') continue
		if (hint.likelySObject === 'FlowInterview') continue
		if (hint.likelySObject === 'Organization') continue
		lookups.push(await lookupRecord(hint, briefing.fields, org))
	}

	if (briefing.apex.className || briefing.apex.classId) {
		if (lookups.length < MAX_LOOKUPS) {
			lookups.push(await lookupApexClass(briefing, org))
		}
	}

	if (briefing.flow.name || briefing.flow.id) {
		if (lookups.length < MAX_LOOKUPS) {
			lookups.push(await lookupFlow(briefing, org))
		}
	}

	if (briefing.flow.interviewId && lookups.length < MAX_LOOKUPS) {
		lookups.push(await lookupFlowInterview(briefing.flow.interviewId, org))
	}

	if (
		(briefing.family === 'apex' ||
			briefing.family === 'flow' ||
			briefing.apex.className) &&
		lookups.length < MAX_LOOKUPS
	) {
		lookups.push(await lookupRecentApexLogs(org))
	}

	const logLookup = lookups.find((lookup) => lookup.kind === 'apex-logs' && lookup.ok)
	const logId = firstString(logLookup?.summary?.latestId)
	if (logId && lookups.length < MAX_LOOKUPS) {
		lookups.push(await lookupApexLogBody(logId, org))
	}
}

async function lookupDescribe(
	sobject: string,
	fields: Array<string>,
	org: OrgContext,
): Promise<TriageLookup> {
	return tryLookup('describe-sobject', async () => {
		const describe = await describeSObject({
			sobject,
			instanceUrl: org.instanceUrl,
			apiVersion: org.apiVersion,
			integrationName: org.integrationName,
		})
		const describedFields = Array.isArray(describe.fields) ? describe.fields : []
		const wanted = new Set(fields.map((field) => field.toLowerCase()))
		const matched = describedFields
			.filter((field): field is JsonRecord => Boolean(field) && typeof field === 'object')
			.filter((field) => wanted.has(String(field.name ?? '').toLowerCase()))
			.slice(0, 12)
			.map((field) => ({
				name: field.name ?? null,
				label: field.label ?? null,
				type: field.type ?? null,
				nillable: field.nillable ?? null,
				createable: field.createable ?? null,
				updateable: field.updateable ?? null,
				custom: field.custom ?? null,
			}))
		return {
			sobject,
			label: typeof describe.label === 'string' ? describe.label : null,
			matchedFieldCount: matched.length,
			fields: matched,
		}
	})
}

async function lookupRecord(
	hint: SalesforceIdHint,
	fields: Array<string>,
	org: OrgContext,
): Promise<TriageLookup> {
	const sobject = hint.likelySObject
	if (!sobject) {
		return { kind: 'get-record', ok: false, skipped: 'No likely sObject for this id.' }
	}
	return tryLookup('get-record', async () => {
		const requested = unique(['Id', ...fields]).slice(0, 8).join(',')
		try {
			const record = await getRecord({
				sobject,
				id: hint.id,
				fields: requested,
				instanceUrl: org.instanceUrl,
				apiVersion: org.apiVersion,
				integrationName: org.integrationName,
			})
			return projectRecord(record, sobject, fields)
		} catch {
			const record = await getRecord({
				sobject,
				id: hint.id,
				fields: 'Id',
				instanceUrl: org.instanceUrl,
				apiVersion: org.apiVersion,
				integrationName: org.integrationName,
			})
			return projectRecord(record, sobject, fields)
		}
	})
}

async function lookupApexClass(
	briefing: ErrorBriefing,
	org: OrgContext,
): Promise<TriageLookup> {
	return tryLookup('apex-class', async () => {
		const clause = briefing.apex.classId
			? `Id = ${soqlString(briefing.apex.classId)}`
			: `Name = ${soqlString(briefing.apex.className ?? '')}`
		const result = await toolingQuery(
			`SELECT Id, Name, Status, ApiVersion, LastModifiedDate, LengthWithoutComments FROM ApexClass WHERE ${clause} LIMIT 3`,
			org,
		)
		return {
			totalSize: result.totalSize,
			records: result.records.slice(0, 3).map((record) => ({
				id: record.Id ?? null,
				name: record.Name ?? null,
				status: record.Status ?? null,
				apiVersion: record.ApiVersion ?? null,
				lastModifiedDate: record.LastModifiedDate ?? null,
				lengthWithoutComments: record.LengthWithoutComments ?? null,
			})),
		}
	})
}

async function lookupFlow(
	briefing: ErrorBriefing,
	org: OrgContext,
): Promise<TriageLookup> {
	return tryLookup('flow', async () => {
		const clause = briefing.flow.id
			? `Id = ${soqlString(briefing.flow.id)}`
			: `ApiName = ${soqlString(briefing.flow.name ?? '')}`
		let result: { totalSize: number; records: Array<JsonRecord> }
		try {
			result = await toolingQuery(
				`SELECT Id, ApiName, DurableId, MasterLabel, ProcessType, Status, LastModifiedDate FROM Flow WHERE ${clause} LIMIT 5`,
				org,
			)
		} catch {
			const fallback = briefing.flow.id
				? `Id = ${soqlString(briefing.flow.id)}`
				: `FullName LIKE ${soqlString(`${briefing.flow.name ?? ''}%`)}`
			result = await toolingQuery(
				`SELECT Id, FullName, Status, ProcessType, LastModifiedDate FROM Flow WHERE ${fallback} LIMIT 5`,
				org,
			)
		}
		return {
			totalSize: result.totalSize,
			records: result.records.slice(0, 5).map((record) => ({
				id: record.Id ?? null,
				apiName: record.ApiName ?? record.FullName ?? null,
				durableId: record.DurableId ?? null,
				label: record.MasterLabel ?? null,
				processType: record.ProcessType ?? null,
				status: record.Status ?? null,
				lastModifiedDate: record.LastModifiedDate ?? null,
			})),
		}
	})
}

async function lookupFlowInterview(
	interviewId: string,
	org: OrgContext,
): Promise<TriageLookup> {
	return tryLookup('flow-interview', async () => {
		const result = await query({
			q: `SELECT Id, InterviewLabel, CurrentElement, InterviewStatus, CreatedDate FROM FlowInterview WHERE Id = ${soqlString(interviewId)}`,
			instanceUrl: org.instanceUrl,
			apiVersion: org.apiVersion,
			integrationName: org.integrationName,
		})
		const record = result.records[0] ?? null
		return {
			totalSize: result.totalSize,
			id: record?.Id ?? null,
			label: record?.InterviewLabel ?? null,
			currentElement: record?.CurrentElement ?? null,
			status: record?.InterviewStatus ?? null,
			createdDate: record?.CreatedDate ?? null,
		}
	})
}

async function lookupRecentApexLogs(org: OrgContext): Promise<TriageLookup> {
	return tryLookup('apex-logs', async () => {
		const result = await toolingQuery(
			'SELECT Id, Status, Operation, Request, DurationMilliseconds, LogLength, LastModifiedDate FROM ApexLog ORDER BY LastModifiedDate DESC LIMIT 5',
			org,
		)
		const records = result.records.slice(0, 5).map((record) => ({
			id: record.Id ?? null,
			status: record.Status ?? null,
			operation: record.Operation ?? null,
			request: record.Request ?? null,
			durationMilliseconds: record.DurationMilliseconds ?? null,
			logLength: record.LogLength ?? null,
			lastModifiedDate: record.LastModifiedDate ?? null,
		}))
		return {
			totalSize: result.totalSize,
			latestId: records[0]?.id ?? null,
			records,
		}
	})
}

async function lookupApexLogBody(id: string, org: OrgContext): Promise<TriageLookup> {
	return tryLookup('apex-log-body', async () => {
		const paths = [`sobjects/ApexLog/${id}/Body`, `tooling/sobjects/ApexLog/${id}/Body`]
		let lastError: unknown
		for (const path of paths) {
			try {
				const result = await request({
					path,
					method: 'GET',
					instanceUrl: org.instanceUrl,
					apiVersion: org.apiVersion,
					integrationName: org.integrationName,
				})
				const body = 'body' in result ? result.body : result
				const text = typeof body === 'string' ? body : JSON.stringify(body)
				return {
					id,
					chars: text.length,
					excerpt: excerptLog(text),
				}
			} catch (error) {
				lastError = error
			}
		}
		throw lastError instanceof Error ? lastError : new Error(projectError(lastError))
	})
}

async function toolingQuery(
	q: string,
	org: OrgContext,
): Promise<{ totalSize: number; records: Array<JsonRecord> }> {
	const result = await request({
		path: 'tooling/query',
		method: 'GET',
		query: { q },
		instanceUrl: org.instanceUrl,
		apiVersion: org.apiVersion,
		integrationName: org.integrationName,
	})
	const body = ('body' in result ? result.body : result) as JsonRecord
	const records = Array.isArray(body.records)
		? body.records.filter((record): record is JsonRecord => Boolean(record) && typeof record === 'object')
		: []
	return {
		totalSize: typeof body.totalSize === 'number' ? body.totalSize : records.length,
		records,
	}
}

async function tryLookup(
	kind: string,
	work: () => Promise<JsonRecord>,
): Promise<TriageLookup> {
	try {
		return { kind, ok: true, summary: await work() }
	} catch (error) {
		return { kind, ok: false, skipped: projectError(error) }
	}
}

function buildSuggestedQueries(briefing: ErrorBriefing): Array<SuggestedQuery> {
	const queries: Array<SuggestedQuery> = []
	const sobject = briefing.likelySObjects[0]
	const recordId = briefing.ids.find(
		(id) => id.likelySObject && id.likelySObject === sobject,
	)?.id

	if (sobject && recordId) {
		const fieldList = unique(['Id', 'Name', ...briefing.fields]).slice(0, 8).join(', ')
		queries.push({
			kind: 'soql',
			q: `SELECT ${fieldList} FROM ${sobject} WHERE Id = ${soqlString(recordId)}`,
			why: `Inspect the ${sobject} named in the error.`,
		})
	} else if (sobject && briefing.fields.length > 0) {
		const fieldList = unique(['Id', ...briefing.fields]).slice(0, 8).join(', ')
		queries.push({
			kind: 'soql',
			q: `SELECT ${fieldList} FROM ${sobject} ORDER BY LastModifiedDate DESC LIMIT 5`,
			why: `Sample recent ${sobject} rows for the fields named in the error.`,
		})
	}

	if (briefing.apex.className || briefing.apex.classId) {
		const clause = briefing.apex.classId
			? `Id = ${soqlString(briefing.apex.classId)}`
			: `Name = ${soqlString(briefing.apex.className ?? '')}`
		queries.push({
			kind: 'tooling',
			q: `SELECT Id, Name, Status, ApiVersion, LastModifiedDate FROM ApexClass WHERE ${clause} LIMIT 5`,
			why: 'Confirm the Apex class named in the stack trace.',
		})
	}

	if (briefing.flow.name || briefing.flow.id) {
		const clause = briefing.flow.id
			? `Id = ${soqlString(briefing.flow.id)}`
			: `ApiName = ${soqlString(briefing.flow.name ?? '')}`
		queries.push({
			kind: 'tooling',
			q: `SELECT Id, ApiName, MasterLabel, ProcessType, Status, LastModifiedDate FROM Flow WHERE ${clause} LIMIT 5`,
			why: 'Confirm the Flow named in the fault.',
		})
	}

	if (briefing.flow.interviewId) {
		queries.push({
			kind: 'soql',
			q: `SELECT Id, InterviewLabel, CurrentElement, InterviewStatus, CreatedDate FROM FlowInterview WHERE Id = ${soqlString(briefing.flow.interviewId)}`,
			why: 'Inspect the Flow interview that faulted.',
		})
	}

	if (briefing.family === 'apex' || briefing.family === 'flow' || briefing.apex.className) {
		queries.push({
			kind: 'tooling',
			q: 'SELECT Id, Status, Operation, Request, DurationMilliseconds, LogLength, LastModifiedDate FROM ApexLog ORDER BY LastModifiedDate DESC LIMIT 10',
			why: 'Find recent debug logs that may include this exception.',
		})
	}

	if (briefing.family === 'query' && briefing.message) {
		queries.push({
			kind: 'soql',
			q: 'SELECT QualifiedApiName FROM EntityDefinition ORDER BY QualifiedApiName LIMIT 20',
			why: 'Confirm the sObject API name before retrying the failed query.',
		})
	}

	if (queries.length === 0) {
		queries.push({
			kind: 'soql',
			q: 'SELECT Id, Status, LastModifiedDate FROM ApexLog ORDER BY LastModifiedDate DESC LIMIT 5',
			why: 'Start with recent debug logs when the payload has no record or class ids.',
		})
	}

	return queries.slice(0, 6)
}

function writeSummary(briefing: ErrorBriefing, dryRun: boolean): string {
	const parts: Array<string> = []
	const code = briefing.errorCodes[0]
	const familyLabel = briefing.family === 'unknown' ? 'Salesforce' : briefing.family
	if (code && briefing.message) {
		parts.push(`${capitalize(familyLabel)} error ${code}: ${clip(briefing.message, 180)}`)
	} else if (briefing.message) {
		parts.push(`${capitalize(familyLabel)} error: ${clip(briefing.message, 180)}`)
	} else if (code) {
		parts.push(`${capitalize(familyLabel)} error ${code}.`)
	} else {
		parts.push('The payload does not include a clear Salesforce error code or message.')
	}

	if (briefing.fields.length > 0) {
		parts.push(`Fields: ${briefing.fields.slice(0, 8).join(', ')}.`)
	}
	if (briefing.likelySObjects.length > 0) {
		parts.push(`Likely sObjects: ${briefing.likelySObjects.slice(0, 5).join(', ')}.`)
	}
	if (briefing.apex.className) {
		const line = briefing.apex.line ? `:${briefing.apex.line}` : ''
		parts.push(`Apex class ${briefing.apex.className}${line}.`)
	}
	if (briefing.flow.name || briefing.flow.element) {
		const element = briefing.flow.element ? ` at ${briefing.flow.element}` : ''
		parts.push(`Flow ${briefing.flow.name ?? 'interview'}${element}.`)
	}
	if (briefing.statusCode) {
		parts.push(`HTTP ${briefing.statusCode}.`)
	}
	parts.push(
		dryRun
			? 'Org lookups are skipped in dryRun; use the suggested SOQL/Tooling queries next.'
			: 'Lookups are read-only; mutating Salesforce stays out of scope for this helper.',
	)
	return parts.join(' ')
}

type ParsedFragment = {
	text: string
	errorCodes: Array<string>
	fields: Array<string>
	ids: Array<SalesforceIdHint>
	statusCode: number | null
	message: string | null
}

function collectFragments(input: TriageErrorInput): Array<ParsedFragment> {
	const seen = new Set<unknown>()
	const fragments: Array<ParsedFragment> = []
	walk(input.error, 0, seen, fragments)
	walk(input.body, 0, seen, fragments)
	walk(input.payload, 0, seen, fragments)
	walk(input.message, 0, seen, fragments)
	walk(input.text, 0, seen, fragments)
	walk(input, 0, seen, fragments)
	return fragments
}

function walk(
	value: unknown,
	depth: number,
	seen: Set<unknown>,
	fragments: Array<ParsedFragment>,
) {
	if (value === undefined || value === null || depth > 6 || fragments.length > 40) return
	if (typeof value === 'string') {
		const trimmed = value.trim()
		if (!trimmed) return
		const parsed = tryParseJson(trimmed)
		if (parsed !== undefined && parsed !== trimmed) {
			walk(parsed, depth + 1, seen, fragments)
			return
		}
		fragments.push(parseTextFragment(trimmed))
		return
	}
	if (typeof value === 'number' || typeof value === 'boolean') return
	if (typeof value !== 'object') return
	if (seen.has(value)) return
	seen.add(value)
	if (Array.isArray(value)) {
		for (const item of value.slice(0, 20)) walk(item, depth + 1, seen, fragments)
		return
	}

	const record = value as JsonRecord
	const errorCode = firstString(record.errorCode ?? record.statusCode ?? record.error)
	const message = firstString(
		record.message ?? record.error_description ?? record.errorMessage ?? record.msg,
	)
	const fields = explicitFields(record.fields)
	const statusCode =
		optionalLooseNumber(record.httpStatusCode) ??
		optionalLooseNumber(record.status) ??
		optionalLooseNumber(record.statusCode)
	if (errorCode || message || fields.length > 0) {
		fragments.push({
			text: [errorCode, message].filter(Boolean).join(': '),
			errorCodes: errorCode && looksLikeErrorCode(errorCode) ? [errorCode] : [],
			fields,
			ids: [],
			statusCode: typeof statusCode === 'number' && statusCode >= 100 ? statusCode : null,
			message,
		})
	}

	for (const [key, nested] of Object.entries(record)) {
		if (
			key === 'error' ||
			key === 'body' ||
			key === 'payload' ||
			key === 'compositeResponse' ||
			key === 'results' ||
			key === 'errors' ||
			key === 'details'
		) {
			walk(nested, depth + 1, seen, fragments)
		}
	}
}

function parseTextFragment(text: string): ParsedFragment {
	return {
		text,
		errorCodes: extractErrorCodes(text),
		fields: extractFieldsFromText(text),
		ids: extractIdsFromText(text),
		statusCode: extractStatusFromText(text),
		message: clip(text.split('\n').map((line) => line.trim()).find(Boolean) ?? text, 300),
	}
}

function classifyFamily(input: {
	errorCodes: Array<string>
	text: string
	flowName: string | null
	className: string | null
	exceptionType: string | null
}): ErrorFamily {
	const text = input.text
	if (input.exceptionType?.startsWith('Flow.') || input.flowName || /flow fault/i.test(text)) {
		return 'flow'
	}
	if (input.exceptionType?.startsWith('System.') || input.className || /\bApex\b/i.test(text)) {
		if (/LimitException|CPU time|Apex CPU/i.test(text)) return 'limits'
		if (/QueryException|\bSOQL\b/i.test(text)) return 'query'
		return 'apex'
	}
	for (const code of input.errorCodes) {
		const family = FAMILY_BY_CODE[code]
		if (family) return family
	}
	if (/REQUIRED_FIELD|VALIDATION|INVALID_EMAIL|STRING_TOO_LONG/i.test(text)) return 'validation'
	if (/MALFORMED_QUERY|INVALID_FIELD|INVALID_TYPE/i.test(text)) return 'query'
	if (/INVALID_SESSION|INSUFFICIENT_ACCESS|INVALID_LOGIN/i.test(text)) return 'auth'
	if (/REQUEST_LIMIT|STORAGE_LIMIT/i.test(text)) return 'limits'
	if (input.errorCodes.length > 0) return 'api'
	return 'unknown'
}

function pickMessage(fragments: Array<ParsedFragment>, input: TriageErrorInput): string | null {
	const explicit = optionalTrim(input.message) ?? optionalTrim(input.text)
	if (explicit && explicit.length < 400) return explicit
	const fromError = fragments.find((fragment) => fragment.message)?.message
	if (fromError) return clip(fromError, 300)
	if (explicit) return clip(explicit, 300)
	return null
}

function pickStatusCode(
	input: TriageErrorInput,
	fragments: Array<ParsedFragment>,
	family: ErrorFamily,
): number | null {
	const explicit = optionalLooseNumber(input.statusCode) ?? optionalLooseNumber(input.status)
	if (explicit && explicit >= 100) return explicit
	const fromFragment = fragments.find((fragment) => fragment.statusCode)?.statusCode
	if (fromFragment) return fromFragment
	if (family === 'auth') return 401
	if (family === 'limits') return 429
	if (family === 'validation' || family === 'query' || family === 'dml') return 400
	return null
}

function explicitFields(value: Array<string> | string | unknown): Array<string> {
	if (typeof value === 'string') {
		return value
			.split(',')
			.map((field) => field.trim())
			.filter((field) => /^[A-Za-z][A-Za-z0-9_.]{0,79}$/.test(field))
	}
	if (!Array.isArray(value)) return []
	return value
		.filter((field): field is string => typeof field === 'string')
		.map((field) => field.trim())
		.filter((field) => /^[A-Za-z][A-Za-z0-9_.]{0,79}$/.test(field))
}

function extractFieldsFromText(text: string): Array<string> {
	const fields: Array<string> = []
	for (const match of text.matchAll(FIELD_LIST_PATTERN)) {
		const inner = match[1]
		if (!inner) continue
		for (const field of inner.split(',')) {
			const trimmed = field.trim()
			if (/^[A-Za-z][A-Za-z0-9_.]{0,79}$/.test(trimmed)) fields.push(trimmed)
		}
	}
	return fields
}

function extractErrorCodes(text: string): Array<string> {
	return unique((text.match(ERROR_CODE_PATTERN) ?? []).filter(looksLikeErrorCode))
}

function looksLikeErrorCode(value: string): boolean {
	return (
		value.includes('_') &&
		value === value.toUpperCase() &&
		value.length >= 6 &&
		!['HTTP', 'API', 'SOQL', 'REST'].includes(value)
	)
}

function extractIdsFromText(text: string): Array<SalesforceIdHint> {
	const ids: Array<SalesforceIdHint> = []
	for (const match of text.matchAll(ID_PATTERN)) {
		const id = match[0]
		if (!id) continue
		const hint = idHint(id)
		if (!hint) continue
		if (id.length === 15 && !hint.likelySObject) continue
		ids.push(hint)
	}
	return ids
}

function explicitIds(input: TriageErrorInput): Array<SalesforceIdHint> {
	const raw = [
		input.recordId,
		input.flowId,
		input.classId,
		...(Array.isArray(input.recordIds) ? input.recordIds : []),
	]
	return raw
		.filter((id): id is string => typeof id === 'string')
		.map((id) => {
			try {
				return idHint(requireSalesforceId(id, 'id'))
			} catch {
				return null
			}
		})
		.filter((hint): hint is SalesforceIdHint => Boolean(hint))
}

function extractStatusFromText(text: string): number | null {
	const match = text.match(/\b(?:HTTP|status(?:Code)?)[:\s]+(\d{3})\b/i)
	if (!match?.[1]) return null
	return Number.parseInt(match[1], 10)
}

function idHint(id: string): SalesforceIdHint | null {
	if (!/^[a-zA-Z0-9]{15}([a-zA-Z0-9]{3})?$/.test(id)) return null
	const prefix = id.slice(0, 3)
	const known = SOBJECT_PREFIXES[prefix]
	const likelySObject =
		known ?? (/^a[a-zA-Z0-9]{2}$/.test(prefix) ? 'CustomObject' : null)
	return { id, prefix, likelySObject }
}

function uniqueIds(ids: Array<SalesforceIdHint>): Array<SalesforceIdHint> {
	const seen = new Set<string>()
	const output: Array<SalesforceIdHint> = []
	for (const hint of ids) {
		if (seen.has(hint.id)) continue
		seen.add(hint.id)
		output.push(hint)
	}
	return output.slice(0, 12)
}

function projectRecord(record: JsonRecord, sobject: string, fields: Array<string>): JsonRecord {
	const extra: JsonRecord = {}
	for (const field of fields.slice(0, 8)) {
		if (field in record) extra[field] = record[field]
	}
	return {
		sobject,
		id: record.Id ?? record.id ?? null,
		name: record.Name ?? record.Subject ?? null,
		lastModifiedDate: record.LastModifiedDate ?? null,
		fields: extra,
	}
}

function excerptLog(text: string): string {
	const lines = text.split(/\r?\n/)
	const interesting = lines.filter((line) =>
		/EXCEPTION_THROWN|FATAL_ERROR|Error|Exception|VALIDATION/i.test(line),
	)
	const source = interesting.length > 0 ? interesting.slice(-20) : lines.slice(-20)
	return clip(source.join('\n'), MAX_LOG_CHARS)
}

function projectError(error: unknown): string {
	if (error instanceof SalesforceApiError) {
		return clip(`Salesforce ${error.status} ${error.statusText}: ${error.message}`, 220)
	}
	if (error instanceof Error) return clip(error.message, 220)
	return clip(String(error), 220)
}

function soqlString(value: string): string {
	return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
}

function unique(values: Array<string>): Array<string> {
	const seen = new Set<string>()
	const output: Array<string> = []
	for (const value of values) {
		const trimmed = value.trim()
		if (!trimmed || seen.has(trimmed)) continue
		seen.add(trimmed)
		output.push(trimmed)
	}
	return output
}

function optionalTrim(value: string | undefined): string | null {
	if (typeof value !== 'string') return null
	const trimmed = value.trim()
	return trimmed.length > 0 ? trimmed : null
}

function firstMatch(text: string, pattern: RegExp): string | null {
	const match = text.match(pattern)
	const value = match?.[1]?.trim()
	return value ? value : null
}

function firstString(value: unknown): string | null {
	return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
}

function optionalLooseNumber(value: unknown): number | undefined {
	if (typeof value === 'number' && Number.isFinite(value)) return value
	if (typeof value === 'string' && value.trim() !== '') {
		const parsed = Number(value)
		if (Number.isFinite(parsed)) return parsed
	}
	return undefined
}

function tryParseJson(text: string): unknown {
	if (!(text.startsWith('{') || text.startsWith('['))) return undefined
	try {
		return JSON.parse(text)
	} catch {
		return undefined
	}
}

function clip(value: string, max: number): string {
	if (value.length <= max) return value
	return `${value.slice(0, max - 1)}…`
}

function capitalize(value: string): string {
	return value.charAt(0).toUpperCase() + value.slice(1)
}