Skip to content
← Public packages

@kody/doctor

Read-only account hygiene: open run errors, job alarm drift, secret host gaps, and plan pressure.

src/index.ts

313 lines · 9.7 KB · TypeScript
import { kody } from 'kody:runtime'

const OPEN_ERROR_SAMPLE = 5
const MAX_DETAIL = 180

export type DoctorSeverity = 'error' | 'warning' | 'info'

export type DoctorFinding = {
	id: string
	severity: DoctorSeverity
	title: string
	detail: string
	nextAsk: string
}

export type CheckInput = {
	/** Optional ISO 8601 lower bound for run_summary and the open-error sample. */
	since?: string
}

export type DoctorReport = {
	ok: boolean
	generatedAt: string
	summary: {
		openErrors: number
		running: number
		enabledJobsWithLastError: number
		killSwitchedJobs: number
		expiredJobs: number
		alarmStatus: string
		secretsMissingHosts: number
		staleForks: number
		usageWarnings: number
	}
	findings: Array<DoctorFinding>
	markdown: string
}

type Settled<T> = { ok: true; value: T } | { ok: false; error: string }

/**
 * Read-only account hygiene report: open run errors, job and alarm problems,
 * user secrets with no allowed hosts, stale community forks, and plan pressure.
 *
 * Never mutates runs, jobs, secrets, or packages.
 */
export default async function check(input: CheckInput = {}): Promise<DoctorReport> {
	const since = typeof input.since === 'string' && input.since.trim() ? input.since : undefined
	const runFilter = since ? { since } : {}

	const [summaryResult, openRunsResult, jobsResult, secretsResult, packagesResult, usageResult] =
		await Promise.all([
			settle(() => kody.runSummary(runFilter)),
			settle(() =>
				kody.runList({
					...runFilter,
					error_triage: 'open',
					status: 'error',
					limit: OPEN_ERROR_SAMPLE,
				}),
			),
			settle(() => kody.jobList({})),
			settle(() => kody.secretList({ scope: 'user' })),
			settle(() => kody.packageList({})),
			settle(() => kody.usageGet({})),
		])

	const findings: Array<DoctorFinding> = []

	pushCapabilityFailure(findings, 'runs.summary', summaryResult)
	pushCapabilityFailure(findings, 'runs.list', openRunsResult)
	pushCapabilityFailure(findings, 'jobs.list', jobsResult)
	pushCapabilityFailure(findings, 'secrets.list', secretsResult)
	pushCapabilityFailure(findings, 'packages.list', packagesResult)
	pushCapabilityFailure(findings, 'account.usage', usageResult)

	const summary = summaryResult.ok ? summaryResult.value : null
	const openRuns = openRunsResult.ok ? openRunsResult.value.runs : []
	const jobs = jobsResult.ok ? jobsResult.value.jobs : []
	const alarm = jobsResult.ok ? jobsResult.value.alarm : null
	const secrets = secretsResult.ok ? secretsResult.value.secrets : []
	const packages = packagesResult.ok ? packagesResult.value.packages : []
	const usage = usageResult.ok ? usageResult.value : null

	if (summary && summary.errors > 0) {
		const bySurface = summary.by_surface
			.filter((row) => row.errors > 0)
			.map((row) => `${row.surface}: ${row.errors}`)
			.join(', ')
		const samples = openRuns
			.slice(0, OPEN_ERROR_SAMPLE)
			.map((run) => {
				const label = run.kody_id ?? run.name ?? run.surface
				return `${run.surface} ${label}: ${clip(run.error_message ?? 'error')}`
			})
			.join(' · ')
		findings.push({
			id: 'open-run-errors',
			severity: 'error',
			title: `${summary.errors} open run error${summary.errors === 1 ? '' : 's'}`,
			detail: [bySurface, samples].filter(Boolean).join('. '),
			nextAsk:
				'Ask me to inspect the latest open error with run_get, then mark noise with run_update.',
		})
	}

	if (summary && summary.running > 0) {
		findings.push({
			id: 'runs-still-running',
			severity: 'info',
			title: `${summary.running} run${summary.running === 1 ? '' : 's'} still running`,
			detail: 'These are in-flight and are not counted as open errors.',
			nextAsk: 'Ask me to list running work with run_list if something looks stuck.',
		})
	}

	const enabledWithLastError = jobs.filter(
		(job) => job.enabled && !job.expired && job.last_run_status === 'error',
	)
	if (enabledWithLastError.length > 0) {
		findings.push({
			id: 'jobs-last-error',
			severity: 'error',
			title: `${enabledWithLastError.length} enabled job${enabledWithLastError.length === 1 ? '' : 's'} last failed`,
			detail: enabledWithLastError
				.slice(0, OPEN_ERROR_SAMPLE)
				.map((job) => `${job.name}: ${clip(job.last_run_error ?? 'error')}`)
				.join(' · '),
			nextAsk: 'Ask me to open one failed job with job_get and its latest run_list rows.',
		})
	}

	const killSwitched = jobs.filter((job) => job.kill_switch_enabled)
	if (killSwitched.length > 0) {
		findings.push({
			id: 'jobs-kill-switch',
			severity: 'warning',
			title: `${killSwitched.length} job${killSwitched.length === 1 ? '' : 's'} on the kill switch`,
			detail: killSwitched
				.slice(0, OPEN_ERROR_SAMPLE)
				.map((job) => job.name)
				.join(', '),
			nextAsk: 'Ask me why the kill switch is on before re-enabling any of these jobs.',
		})
	}

	const expired = jobs.filter((job) => job.expired)
	if (expired.length > 0) {
		findings.push({
			id: 'jobs-expired',
			severity: 'warning',
			title: `${expired.length} expired job${expired.length === 1 ? '' : 's'}`,
			detail: expired
				.slice(0, OPEN_ERROR_SAMPLE)
				.map((job) => job.name)
				.join(', '),
			nextAsk: 'Ask me whether these schedules should be extended or removed.',
		})
	}

	if (alarm && (alarm.status === 'out_of_sync' || alarm.status === 'missing_binding')) {
		findings.push({
			id: 'job-alarm',
			severity: 'error',
			title:
				alarm.status === 'missing_binding'
					? 'Job manager alarm binding is missing'
					: 'Job manager alarm is out of sync',
			detail: [
				`status=${alarm.status}`,
				alarm.next_runnable_job_id ? `next=${alarm.next_runnable_job_id}` : null,
				alarm.alarm_scheduled_for ? `scheduled=${alarm.alarm_scheduled_for}` : null,
			]
				.filter(Boolean)
				.join(' · '),
			nextAsk: 'Ask me to inspect job_list alarm state and get the job manager back in sync.',
		})
	}

	const secretsMissingHosts = secrets.filter((secret) => secret.allowed_hosts.length === 0)
	if (secretsMissingHosts.length > 0) {
		findings.push({
			id: 'secrets-missing-hosts',
			severity: 'warning',
			title: `${secretsMissingHosts.length} user secret${secretsMissingHosts.length === 1 ? '' : 's'} with no allowed hosts`,
			detail: secretsMissingHosts
				.slice(0, 8)
				.map((secret) => secret.name)
				.join(', '),
			nextAsk:
				'Ask me to open /account/secrets and add allowed hosts before using these in outbound fetch.',
		})
	}

	const staleForks = packages.filter((pkg) => pkg.source_listing_id && pkg.listing_current === false)
	if (staleForks.length > 0) {
		findings.push({
			id: 'stale-forks',
			severity: 'warning',
			title: `${staleForks.length} forked package${staleForks.length === 1 ? '' : 's'} whose source listing is gone`,
			detail: staleForks
				.slice(0, OPEN_ERROR_SAMPLE)
				.map((pkg) => pkg.name)
				.join(', '),
			nextAsk:
				'Ask me to review each stale fork and either replace it from a current listing or delete it.',
		})
	}

	const usageWarnings = usage?.warnings ?? []
	const warned = new Set(usageWarnings.map((row) => row.resource))
	const pressure = [
		...usageWarnings,
		...(usage?.resources ?? []).filter(
			(resource) => resource.overEightyPercent && !warned.has(resource.resource),
		),
	]
	if (pressure.length > 0) {
		findings.push({
			id: 'usage-pressure',
			severity: 'warning',
			title: `${pressure.length} plan limit${pressure.length === 1 ? '' : 's'} near or over capacity`,
			detail: pressure
				.slice(0, 6)
				.map((row) => `${row.label}: ${row.current}/${row.limit}`)
				.join(' · '),
			nextAsk: `Ask me how to reduce ${pressure[0]?.label ?? 'usage'}: ${clip(pressure[0]?.howToReduce ?? 'review plan limits')}`,
		})
	}

	const reportSummary = {
		openErrors: summary?.errors ?? 0,
		running: summary?.running ?? 0,
		enabledJobsWithLastError: enabledWithLastError.length,
		killSwitchedJobs: killSwitched.length,
		expiredJobs: expired.length,
		alarmStatus: alarm?.status ?? 'unknown',
		secretsMissingHosts: secretsMissingHosts.length,
		staleForks: staleForks.length,
		usageWarnings: pressure.length,
	}

	const ok = findings.every((finding) => finding.severity !== 'error')
	return {
		ok,
		generatedAt: new Date().toISOString(),
		summary: reportSummary,
		findings,
		markdown: formatMarkdown({ ok, findings, summary: reportSummary }),
	}
}

async function settle<T>(fn: () => Promise<T>): Promise<Settled<T>> {
	try {
		return { ok: true, value: await fn() }
	} catch (error) {
		return {
			ok: false,
			error: error instanceof Error ? error.message : String(error),
		}
	}
}

function pushCapabilityFailure(findings: Array<DoctorFinding>, id: string, result: Settled<unknown>) {
	if (result.ok) return
	findings.push({
		id: `capability-failed:${id}`,
		severity: 'error',
		title: `Could not read ${id}`,
		detail: clip(result.error),
		nextAsk: `Ask me to retry ${id} directly and inspect the error.`,
	})
}

function clip(value: string) {
	const text = value.replace(/\s+/g, ' ').trim()
	if (text.length <= MAX_DETAIL) return text
	return `${text.slice(0, MAX_DETAIL - 1)}…`
}

function formatMarkdown(input: {
	ok: boolean
	findings: Array<DoctorFinding>
	summary: DoctorReport['summary']
}) {
	const lines = [
		input.ok ? '# Doctor: ok' : '# Doctor: needs attention',
		'',
		`- Open errors: ${input.summary.openErrors}`,
		`- Running: ${input.summary.running}`,
		`- Enabled jobs last failed: ${input.summary.enabledJobsWithLastError}`,
		`- Kill switch: ${input.summary.killSwitchedJobs}`,
		`- Expired jobs: ${input.summary.expiredJobs}`,
		`- Alarm: ${input.summary.alarmStatus}`,
		`- Secrets missing hosts: ${input.summary.secretsMissingHosts}`,
		`- Stale forks: ${input.summary.staleForks}`,
		`- Plan pressure: ${input.summary.usageWarnings}`,
	]
	if (input.findings.length === 0) {
		lines.push('', 'No findings.')
		return lines.join('\n')
	}
	lines.push('', '## Findings')
	for (const finding of input.findings) {
		lines.push(
			'',
			`### ${finding.severity}: ${finding.title}`,
			finding.detail,
			`Next: ${finding.nextAsk}`,
		)
	}
	return lines.join('\n')
}